From d66e0e99e59893a8e541532aafb6f14995a74106 Mon Sep 17 00:00:00 2001 From: SkiTee3000 <39069192+SkiTee3000@users.noreply.github.com> Date: Sat, 19 Sep 2026 18:02:54 +0300 Subject: [PATCH 1/2] perf(shared): scan PATH once per command before spawning, not on every spawn resolveSpawnCommand walked PATH x PATHEXT with a stat per candidate on every spawn. The result is kept for 30 s in the existing command resolution cache, keyed by platform, PATH, PATHEXT and command. Explicit paths and failed lookups are never cached, so a newly installed provider is found on the next spawn. --- packages/shared/src/shell.test.ts | 49 +++++++++++++++++++++++++++++-- packages/shared/src/shell.ts | 26 +++++++++++++++- 2 files changed, 72 insertions(+), 3 deletions(-) diff --git a/packages/shared/src/shell.test.ts b/packages/shared/src/shell.test.ts index 6c89e49d017f..adda1712af15 100644 --- a/packages/shared/src/shell.test.ts +++ b/packages/shared/src/shell.test.ts @@ -515,7 +515,10 @@ effectIt.layer(NodeServices.layer)("resolveSpawnCommand", (it) => { Effect.gen(function* () { const command = yield* resolveSpawnCommand("node.exe", ["script.js", "hello & goodbye"], { env: { PATH: "", PATHEXT: ".COM;.EXE;.BAT;.CMD" }, - }).pipe(Effect.provideService(HostProcessPlatform, "win32")); + }).pipe( + Effect.provideService(HostProcessPlatform, "win32"), + Effect.provideService(CommandResolutionCache, new Map()), + ); expect(command).toEqual({ command: "node.exe", @@ -533,6 +536,7 @@ effectIt.layer(NodeServices.layer)("resolveSpawnCommand", (it) => { { env: { PATH: "", PATHEXT: ".COM;.EXE;.BAT;.CMD" } }, ).pipe( Effect.provideService(HostProcessPlatform, "win32"), + Effect.provideService(CommandResolutionCache, new Map()), Effect.provideService( SpawnExecutableResolution, () => "C:\\Program Files\\npm & tools\\vp.cmd", @@ -559,6 +563,7 @@ effectIt.layer(NodeServices.layer)("resolveSpawnCommand", (it) => { extendEnv: true, }).pipe( Effect.provideService(HostProcessPlatform, "win32"), + Effect.provideService(CommandResolutionCache, new Map()), Effect.provideService(HostProcessEnvironment, { PATH: "C:\\Users\\tester\\AppData\\Roaming\\npm", PATHEXT: ".COM;.EXE;.BAT;.CMD", @@ -577,11 +582,51 @@ effectIt.layer(NodeServices.layer)("resolveSpawnCommand", (it) => { }), ); + it.effect("scans PATH once per command until the search environment changes", () => + Effect.gen(function* () { + const scans: Array = []; + const resolve = (command: string, path: string) => + resolveSpawnCommand(command, [], { env: { PATH: path, PATHEXT: ".EXE" } }).pipe( + Effect.provideService(HostProcessPlatform, "win32"), + Effect.provideService(SpawnExecutableResolution, (name, _platform, env) => { + scans.push(`${name}@${env.PATH}`); + return name === "missing" ? undefined : `${env.PATH}\\${name}.exe`; + }), + ); + + expect((yield* resolve("git", "C:\\one")).command).toBe("C:\\one\\git.exe"); + expect((yield* resolve("git", "C:\\one")).command).toBe("C:\\one\\git.exe"); + // A failed spawn is how a provider reports "not installed", so a miss + // must clear the moment the binary appears. + yield* resolve("missing", "C:\\one"); + yield* resolve("missing", "C:\\one"); + expect((yield* resolve("git", "C:\\two")).command).toBe("C:\\two\\git.exe"); + // Callers probe explicit paths they may have just written. + yield* resolve("C:\\tools\\git.exe", "C:\\one"); + yield* resolve("C:\\tools\\git.exe", "C:\\one"); + expect(scans).toEqual([ + "git@C:\\one", + "missing@C:\\one", + "missing@C:\\one", + "git@C:\\two", + "C:\\tools\\git.exe@C:\\one", + "C:\\tools\\git.exe@C:\\one", + ]); + + yield* TestClock.adjust("31 seconds"); + yield* resolve("git", "C:\\one"); + expect(scans).toHaveLength(7); + }).pipe(Effect.provideService(CommandResolutionCache, new Map())), + ); + it.effect("does not fall back to a shell for unresolved Windows commands", () => Effect.gen(function* () { const command = yield* resolveSpawnCommand("missing & calc", ["unsafe & value"], { env: { PATH: "", PATHEXT: ".COM;.EXE;.BAT;.CMD" }, - }).pipe(Effect.provideService(HostProcessPlatform, "win32")); + }).pipe( + Effect.provideService(HostProcessPlatform, "win32"), + Effect.provideService(CommandResolutionCache, new Map()), + ); expect(command).toEqual({ command: "missing & calc", diff --git a/packages/shared/src/shell.ts b/packages/shared/src/shell.ts index 6cb08d2890be..e990b911942d 100644 --- a/packages/shared/src/shell.ts +++ b/packages/shared/src/shell.ts @@ -700,7 +700,31 @@ export const resolveSpawnCommand = Effect.fnUntraced(function* ( ? { ...hostEnvironment, ...options.env } : options.env; const resolveExecutable = yield* SpawnExecutableResolution; - const resolvedCommand = resolveExecutable(command, platform, env) ?? command; + // The scan is synchronous and runs before every child process, so it shares + // the PATH scan cache above. Explicit paths stay uncached for the same reason, + // and so do misses: a failed spawn is how providers report "not installed", + // and that has to clear the moment the binary appears. + const explicitPath = command.includes("/") || command.includes("\\"); + const cache = yield* CommandResolutionCache; + const cacheKey = [ + "spawn", + platform, + resolvePathEnvironmentVariable(env), + resolveWindowsPathExtensions(env).join(";"), + command, + ].join(COMMAND_RESOLUTION_CACHE_KEY_SEPARATOR); + const nowNanos = yield* Clock.currentTimeNanos; + const cached = explicitPath ? undefined : cache.get(cacheKey); + let resolvedExecutable: string | null; + if (cached !== undefined && cached.expiresAtNanos > nowNanos) { + resolvedExecutable = cached.resolvedPath; + } else { + resolvedExecutable = resolveExecutable(command, platform, env) ?? null; + if (!explicitPath && resolvedExecutable !== null) { + cacheCommandResolution(cache, cacheKey, resolvedExecutable, nowNanos); + } + } + const resolvedCommand = resolvedExecutable ?? command; const extension = NodePath.win32.extname(resolvedCommand).toLowerCase(); if (extension !== ".cmd" && extension !== ".bat") { return { command: resolvedCommand, args: [...args], shell: false }; From 37d114f65b8386ced7c520de755480f1a7379d6b Mon Sep 17 00:00:00 2001 From: SkiTee3000 <39069192+SkiTee3000@users.noreply.github.com> Date: Sat, 19 Sep 2026 19:05:03 +0300 Subject: [PATCH 2/2] fix(shared): keep cached spawn resolutions apart per resolver --- packages/shared/src/shell.test.ts | 18 +++++++++++++----- packages/shared/src/shell.ts | 13 +++++++++++++ 2 files changed, 26 insertions(+), 5 deletions(-) diff --git a/packages/shared/src/shell.test.ts b/packages/shared/src/shell.test.ts index adda1712af15..4e27aa49fcfc 100644 --- a/packages/shared/src/shell.test.ts +++ b/packages/shared/src/shell.test.ts @@ -24,6 +24,7 @@ import { resolveSpawnCommand, resolveWindowsEnvironment, SpawnExecutableResolution, + type SpawnExecutableResolver, WindowsShellEnvironment, withPathDirectoryListings, type WindowsShellEnvironmentReader, @@ -585,13 +586,14 @@ effectIt.layer(NodeServices.layer)("resolveSpawnCommand", (it) => { it.effect("scans PATH once per command until the search environment changes", () => Effect.gen(function* () { const scans: Array = []; - const resolve = (command: string, path: string) => + const scan: SpawnExecutableResolver = (name, _platform, env) => { + scans.push(`${name}@${env.PATH}`); + return name === "missing" ? undefined : `${env.PATH}\\${name}.exe`; + }; + const resolve = (command: string, path: string, resolver = scan) => resolveSpawnCommand(command, [], { env: { PATH: path, PATHEXT: ".EXE" } }).pipe( Effect.provideService(HostProcessPlatform, "win32"), - Effect.provideService(SpawnExecutableResolution, (name, _platform, env) => { - scans.push(`${name}@${env.PATH}`); - return name === "missing" ? undefined : `${env.PATH}\\${name}.exe`; - }), + Effect.provideService(SpawnExecutableResolution, resolver), ); expect((yield* resolve("git", "C:\\one")).command).toBe("C:\\one\\git.exe"); @@ -616,6 +618,12 @@ effectIt.layer(NodeServices.layer)("resolveSpawnCommand", (it) => { yield* TestClock.adjust("31 seconds"); yield* resolve("git", "C:\\one"); expect(scans).toHaveLength(7); + + // Another resolver sharing the cache gets its own answer, not the cached one. + const elsewhere = yield* resolve("git", "C:\\one", () => "D:\\elsewhere\\git.exe"); + expect(elsewhere.command).toBe("D:\\elsewhere\\git.exe"); + expect((yield* resolve("git", "C:\\one")).command).toBe("C:\\one\\git.exe"); + expect(scans).toHaveLength(7); }).pipe(Effect.provideService(CommandResolutionCache, new Map())), ); diff --git a/packages/shared/src/shell.ts b/packages/shared/src/shell.ts index e990b911942d..e0bfc322142f 100644 --- a/packages/shared/src/shell.ts +++ b/packages/shared/src/shell.ts @@ -559,6 +559,18 @@ export const withPathDirectoryListings = (effect: Effect.Effect(); +function spawnResolverCacheId(resolver: SpawnExecutableResolver): number { + const known = spawnResolverCacheIds.get(resolver); + if (known !== undefined) return known; + const id = spawnResolverCacheIdCount++; + spawnResolverCacheIds.set(resolver, id); + return id; +} + function cacheCommandResolution( cache: Map, cacheKey: string, @@ -708,6 +720,7 @@ export const resolveSpawnCommand = Effect.fnUntraced(function* ( const cache = yield* CommandResolutionCache; const cacheKey = [ "spawn", + String(spawnResolverCacheId(resolveExecutable)), platform, resolvePathEnvironmentVariable(env), resolveWindowsPathExtensions(env).join(";"),