Skip to content

tsserver hangs in Yarn PnP projects β€” incorrect directory watcher due to unchecked indexOf returning -1Β #63373

@sebaspf

Description

@sebaspf

πŸ”Ž Search Terms

tsserver hang, Yarn PnP, isInNodeModules, nodeModulesPathPart, watchNodeModulesForPackageJsonChanges, createModuleSpecifierCache, VS Code freeze on save

πŸ•— Version & Regression Information

Confirmed on TypeScript 5.8.3 and 6.0.2 (latest). This affects TypeScript versions within a Yarn PnP Project.

⏯ Playground Link

N/A β€” requires a Yarn PnP project and VS Code.

πŸ’» Code

There are two instances of the same class of bug β€” unchecked indexOf/lastIndexOf returning -1 when a path doesn't contain the literal string /node_modules/, which is always the case in Yarn PnP projects.

Bug 1: src/server/moduleSpecifierCache.ts β€” createModuleSpecifierCache β†’ set()

https://github.com/microsoft/TypeScript/blob/5f435057/src/server/moduleSpecifierCache.ts#L43

 if (p.isInNodeModules) {
     // No trailing slash
     const nodeModulesPath = p.path.substring(0, p.path.indexOf(nodeModulesPathPart) + nodeModulesPathPart.length - 1);
     //                                          ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
     //                                          Returns -1 in Yarn PnP (no /node_modules/ in path)
     const key = host.toPath(nodeModulesPath);
     if (!containedNodeModulesWatchers?.has(key)) {
         (containedNodeModulesWatchers ||= new Map()).set(
             key,
             host.watchNodeModulesForPackageJsonChanges(nodeModulesPath), // ← watches wrong dir!
         );
     }
 }

In Yarn PnP, TypeScript's own PnP integration (in src/compiler/moduleSpecifiers.ts) correctly marks cross-package module paths with isInNodeModules = true even when the path doesn't literally contain /node_modules/ (packages live in
.yarn/cache/*.zip paths). However, this code then assumes the path must contain the literal string /node_modules/.

When indexOf returns -1, the arithmetic becomes:

 substring(0, -1 + "/node_modules/".length - 1)
 = substring(0, -1 + 14 - 1)
 = substring(0, 12)

For a project in for example /home/myHome/projects/myProject the substring(0, 12) produces /home/myHome/ which is a valid path. This is then passed to host.watchNodeModulesForPackageJsonChanges(), which calls
watchPackageJsonsInNodeModules() β†’ createNodeModulesWatcher() β†’ watchFactory.watchDirectory(), creating a recursive directory watcher on the user's entire home directory.

This causes tsserver to enumerate and process every file under $HOME, hanging VS Code's extension host for minutes or indefinitely.

Bug 2: src/compiler/moduleNameResolver.ts β€” readPackageJsonPeerDependencies()

 const nodeModules = packageDirectory.substring(
     0,
     packageDirectory.lastIndexOf("node_modules") + "node_modules".length
 ) + directorySeparator;

Same pattern: lastIndexOf returns -1, producing substring(0, 11) + / β†’ first 12 chars of the path. Then getPackageJsonInfo(nodeModules + key, ...) tries to resolve peer dependencies under that incorrect directory.

πŸ™ Actual behavior

Saving any file in VS Code with a Yarn PnP workspace causes tsserver to set up a recursive file watcher on the user's home directory (or an arbitrary prefix of the path, depending on home directory length). The VS Code GUI freezes.

Yarn patches Typescript setting ο»ΏisInNodeModules = true; even though there is no string /node_modules/ in the path. ο»ΏThe Yarn patch doesn't touch the downstream moduleSpecifierCache.ts which assumes isInNodeModules === true means the path contains the literal string /node_modules/. So indexOf at moduleSpecifierCache.ts:42 would never return -1 in an unpatched TypeScript β€” the bug is latent but unreachable without Yarn PnP.

Without this check the module path matches the firs 12 letters in the path, that in my case happens to be my home directory (e.g. /home/myHome/) making Typescript to watch all files in my $HOME.

πŸ™‚ Expected behavior

When indexOf(nodeModulesPathPart) returns -1, the watcher/resolution should be skipped entirely (the path has no node_modules directory to watch).

Suggested fix

Bug 1 β€” Guard against -1:

 if (p.isInNodeModules) {
     const nodeModulesIndex = p.path.indexOf(nodeModulesPathPart);
     if (nodeModulesIndex !== -1) {
         const nodeModulesPath = p.path.substring(0, nodeModulesIndex + nodeModulesPathPart.length - 1);
         const key = host.toPath(nodeModulesPath);
         if (!containedNodeModulesWatchers?.has(key)) {
             (containedNodeModulesWatchers ||= new Map()).set(
                 key,
                 host.watchNodeModulesForPackageJsonChanges(nodeModulesPath),
             );
         }
     }
 }

Bug 2 β€” Early return when node_modules not in path:

 const nodeModulesIdx = packageDirectory.lastIndexOf("node_modules");
 if (nodeModulesIdx === -1) return undefined;
 const nodeModules = packageDirectory.substring(0, nodeModulesIdx + "node_modules".length) + directorySeparator;

Additional information about the issue

Environment:

  • TypeScript
    5.8.3 and 6.0.2
  • Yarn
    4.x with PnP (nodeLinker: pnp)
  • VS Code with Yarn PnP TypeScript SDK (@yarnpkg/sdks)
  • Linux (home directory path length happens to match the incorrect substring result)

Workaround:
create a link in some other place pointing to the repo and start VS Code with the yarn PnP SDK and Typescript support from there

cd /tmp
ln -s /home/myHome/myProject
cd myProject
code .

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions