authorgravatar for 39069192+SkiTee3000@users.noreply.github.comSkiTee3000 <39069192+SkiTee3000@users.noreply.github.com> 2026-10-06 07:12:52+03:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-06 04:12:52+00:00
log71964e670ff212006ebb8de26585649c2adced68
treed9dfe8ab2fec54a9acfa3e19883e4cbaba5948eb
parent447046b589140a50a4a782453133da83ff77e85e
signature Signed by PGP key B5690EEEBB952194

perf(shared): scan PATH once per command before spawning, not on every spawn (#12600)


2 files changed, 93 insertions(+), 3 deletions(-)

packages/shared/src/shell.test.ts+55-2
...@@ -24,6 +24,7 @@ import {...@@ -24,6 +24,7 @@ import {
24 resolveSpawnCommand,24 resolveSpawnCommand,
25 resolveWindowsEnvironment,25 resolveWindowsEnvironment,
26 SpawnExecutableResolution,26 SpawnExecutableResolution,
27 type SpawnExecutableResolver,
27 WindowsShellEnvironment,28 WindowsShellEnvironment,
28 withPathDirectoryListings,29 withPathDirectoryListings,
29 type WindowsShellEnvironmentReader,30 type WindowsShellEnvironmentReader,
...@@ -515,7 +516,10 @@ effectIt.layer(NodeServices.layer)("resolveSpawnCommand", (it) => {...@@ -515,7 +516,10 @@ effectIt.layer(NodeServices.layer)("resolveSpawnCommand", (it) => {
515 Effect.gen(function* () {516 Effect.gen(function* () {
516 const command = yield* resolveSpawnCommand("node.exe", ["script.js", "hello & goodbye"], {517 const command = yield* resolveSpawnCommand("node.exe", ["script.js", "hello & goodbye"], {
517 env: { PATH: "", PATHEXT: ".COM;.EXE;.BAT;.CMD" },518 env: { PATH: "", PATHEXT: ".COM;.EXE;.BAT;.CMD" },
518 }).pipe(Effect.provideService(HostProcessPlatform, "win32"));519 }).pipe(
520 Effect.provideService(HostProcessPlatform, "win32"),
521 Effect.provideService(CommandResolutionCache, new Map()),
522 );
519523
520 expect(command).toEqual({524 expect(command).toEqual({
521 command: "node.exe",525 command: "node.exe",
...@@ -533,6 +537,7 @@ effectIt.layer(NodeServices.layer)("resolveSpawnCommand", (it) => {...@@ -533,6 +537,7 @@ effectIt.layer(NodeServices.layer)("resolveSpawnCommand", (it) => {
533 { env: { PATH: "", PATHEXT: ".COM;.EXE;.BAT;.CMD" } },537 { env: { PATH: "", PATHEXT: ".COM;.EXE;.BAT;.CMD" } },
534 ).pipe(538 ).pipe(
535 Effect.provideService(HostProcessPlatform, "win32"),539 Effect.provideService(HostProcessPlatform, "win32"),
540 Effect.provideService(CommandResolutionCache, new Map()),
536 Effect.provideService(541 Effect.provideService(
537 SpawnExecutableResolution,542 SpawnExecutableResolution,
538 () => "C:\\Program Files\\npm & tools\\vp.cmd",543 () => "C:\\Program Files\\npm & tools\\vp.cmd",
...@@ -559,6 +564,7 @@ effectIt.layer(NodeServices.layer)("resolveSpawnCommand", (it) => {...@@ -559,6 +564,7 @@ effectIt.layer(NodeServices.layer)("resolveSpawnCommand", (it) => {
559 extendEnv: true,564 extendEnv: true,
560 }).pipe(565 }).pipe(
561 Effect.provideService(HostProcessPlatform, "win32"),566 Effect.provideService(HostProcessPlatform, "win32"),
567 Effect.provideService(CommandResolutionCache, new Map()),
562 Effect.provideService(HostProcessEnvironment, {568 Effect.provideService(HostProcessEnvironment, {
563 PATH: "C:\\Users\\tester\\AppData\\Roaming\\npm",569 PATH: "C:\\Users\\tester\\AppData\\Roaming\\npm",
564 PATHEXT: ".COM;.EXE;.BAT;.CMD",570 PATHEXT: ".COM;.EXE;.BAT;.CMD",
...@@ -577,11 +583,58 @@ effectIt.layer(NodeServices.layer)("resolveSpawnCommand", (it) => {...@@ -577,11 +583,58 @@ effectIt.layer(NodeServices.layer)("resolveSpawnCommand", (it) => {
577 }),583 }),
578 );584 );
579585
586 it.effect("scans PATH once per command until the search environment changes", () =>
587 Effect.gen(function* () {
588 const scans: Array<string> = [];
589 const scan: SpawnExecutableResolver = (name, _platform, env) => {
590 scans.push(`${name}@${env.PATH}`);
591 return name === "missing" ? undefined : `${env.PATH}\\${name}.exe`;
592 };
593 const resolve = (command: string, path: string, resolver = scan) =>
594 resolveSpawnCommand(command, [], { env: { PATH: path, PATHEXT: ".EXE" } }).pipe(
595 Effect.provideService(HostProcessPlatform, "win32"),
596 Effect.provideService(SpawnExecutableResolution, resolver),
597 );
598
599 expect((yield* resolve("git", "C:\\one")).command).toBe("C:\\one\\git.exe");
600 expect((yield* resolve("git", "C:\\one")).command).toBe("C:\\one\\git.exe");
601 // A failed spawn is how a provider reports "not installed", so a miss
602 // must clear the moment the binary appears.
603 yield* resolve("missing", "C:\\one");
604 yield* resolve("missing", "C:\\one");
605 expect((yield* resolve("git", "C:\\two")).command).toBe("C:\\two\\git.exe");
606 // Callers probe explicit paths they may have just written.
607 yield* resolve("C:\\tools\\git.exe", "C:\\one");
608 yield* resolve("C:\\tools\\git.exe", "C:\\one");
609 expect(scans).toEqual([
610 "git@C:\\one",
611 "missing@C:\\one",
612 "missing@C:\\one",
613 "git@C:\\two",
614 "C:\\tools\\git.exe@C:\\one",
615 "C:\\tools\\git.exe@C:\\one",
616 ]);
617
618 yield* TestClock.adjust("31 seconds");
619 yield* resolve("git", "C:\\one");
620 expect(scans).toHaveLength(7);
621
622 // Another resolver sharing the cache gets its own answer, not the cached one.
623 const elsewhere = yield* resolve("git", "C:\\one", () => "D:\\elsewhere\\git.exe");
624 expect(elsewhere.command).toBe("D:\\elsewhere\\git.exe");
625 expect((yield* resolve("git", "C:\\one")).command).toBe("C:\\one\\git.exe");
626 expect(scans).toHaveLength(7);
627 }).pipe(Effect.provideService(CommandResolutionCache, new Map())),
628 );
629
580 it.effect("does not fall back to a shell for unresolved Windows commands", () =>630 it.effect("does not fall back to a shell for unresolved Windows commands", () =>
581 Effect.gen(function* () {631 Effect.gen(function* () {
582 const command = yield* resolveSpawnCommand("missing & calc", ["unsafe & value"], {632 const command = yield* resolveSpawnCommand("missing & calc", ["unsafe & value"], {
583 env: { PATH: "", PATHEXT: ".COM;.EXE;.BAT;.CMD" },633 env: { PATH: "", PATHEXT: ".COM;.EXE;.BAT;.CMD" },
584 }).pipe(Effect.provideService(HostProcessPlatform, "win32"));634 }).pipe(
635 Effect.provideService(HostProcessPlatform, "win32"),
636 Effect.provideService(CommandResolutionCache, new Map()),
637 );
585638
586 expect(command).toEqual({639 expect(command).toEqual({
587 command: "missing & calc",640 command: "missing & calc",
packages/shared/src/shell.ts+38-1
...@@ -559,6 +559,18 @@ export const withPathDirectoryListings = <A, E, R>(effect: Effect.Effect<A, E, R...@@ -559,6 +559,18 @@ export const withPathDirectoryListings = <A, E, R>(effect: Effect.Effect<A, E, R
559 return yield* effect.pipe(Effect.provideService(PathDirectoryListings, listings));559 return yield* effect.pipe(Effect.provideService(PathDirectoryListings, listings));
560 });560 });
561561
562// An injected resolver may answer differently for the same search, so its
563// entries are kept apart from every other resolver's.
564let spawnResolverCacheIdCount = 0;
565const spawnResolverCacheIds = new WeakMap<SpawnExecutableResolver, number>();
566function spawnResolverCacheId(resolver: SpawnExecutableResolver): number {
567 const known = spawnResolverCacheIds.get(resolver);
568 if (known !== undefined) return known;
569 const id = spawnResolverCacheIdCount++;
570 spawnResolverCacheIds.set(resolver, id);
571 return id;
572}
573
562function cacheCommandResolution(574function cacheCommandResolution(
563 cache: Map<string, CommandResolutionCacheEntry>,575 cache: Map<string, CommandResolutionCacheEntry>,
564 cacheKey: string,576 cacheKey: string,
...@@ -700,7 +712,32 @@ export const resolveSpawnCommand = Effect.fnUntraced(function* (...@@ -700,7 +712,32 @@ export const resolveSpawnCommand = Effect.fnUntraced(function* (
700 ? { ...hostEnvironment, ...options.env }712 ? { ...hostEnvironment, ...options.env }
701 : options.env;713 : options.env;
702 const resolveExecutable = yield* SpawnExecutableResolution;714 const resolveExecutable = yield* SpawnExecutableResolution;
703 const resolvedCommand = resolveExecutable(command, platform, env) ?? command;715 // The scan is synchronous and runs before every child process, so it shares
716 // the PATH scan cache above. Explicit paths stay uncached for the same reason,
717 // and so do misses: a failed spawn is how providers report "not installed",
718 // and that has to clear the moment the binary appears.
719 const explicitPath = command.includes("/") || command.includes("\\");
720 const cache = yield* CommandResolutionCache;
721 const cacheKey = [
722 "spawn",
723 String(spawnResolverCacheId(resolveExecutable)),
724 platform,
725 resolvePathEnvironmentVariable(env),
726 resolveWindowsPathExtensions(env).join(";"),
727 command,
728 ].join(COMMAND_RESOLUTION_CACHE_KEY_SEPARATOR);
729 const nowNanos = yield* Clock.currentTimeNanos;
730 const cached = explicitPath ? undefined : cache.get(cacheKey);
731 let resolvedExecutable: string | null;
732 if (cached !== undefined && cached.expiresAtNanos > nowNanos) {
733 resolvedExecutable = cached.resolvedPath;
734 } else {
735 resolvedExecutable = resolveExecutable(command, platform, env) ?? null;
736 if (!explicitPath && resolvedExecutable !== null) {
737 cacheCommandResolution(cache, cacheKey, resolvedExecutable, nowNanos);
738 }
739 }
740 const resolvedCommand = resolvedExecutable ?? command;
704 const extension = NodePath.win32.extname(resolvedCommand).toLowerCase();741 const extension = NodePath.win32.extname(resolvedCommand).toLowerCase();
705 if (extension !== ".cmd" && extension !== ".bat") {742 if (extension !== ".cmd" && extension !== ".bat") {
706 return { command: resolvedCommand, args: [...args], shell: false };743 return { command: resolvedCommand, args: [...args], shell: false };