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 {
2424 resolveSpawnCommand,
2525 resolveWindowsEnvironment,
2626 SpawnExecutableResolution,
27 type SpawnExecutableResolver,
2728 WindowsShellEnvironment,
2829 withPathDirectoryListings,
2930 type WindowsShellEnvironmentReader,
......@@ -515,7 +516,10 @@ effectIt.layer(NodeServices.layer)("resolveSpawnCommand", (it) => {
515516 Effect.gen(function* () {
516517 const command = yield* resolveSpawnCommand("node.exe", ["script.js", "hello & goodbye"], {
517518 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
520524 expect(command).toEqual({
521525 command: "node.exe",
......@@ -533,6 +537,7 @@ effectIt.layer(NodeServices.layer)("resolveSpawnCommand", (it) => {
533537 { env: { PATH: "", PATHEXT: ".COM;.EXE;.BAT;.CMD" } },
534538 ).pipe(
535539 Effect.provideService(HostProcessPlatform, "win32"),
540 Effect.provideService(CommandResolutionCache, new Map()),
536541 Effect.provideService(
537542 SpawnExecutableResolution,
538543 () => "C:\\Program Files\\npm & tools\\vp.cmd",
......@@ -559,6 +564,7 @@ effectIt.layer(NodeServices.layer)("resolveSpawnCommand", (it) => {
559564 extendEnv: true,
560565 }).pipe(
561566 Effect.provideService(HostProcessPlatform, "win32"),
567 Effect.provideService(CommandResolutionCache, new Map()),
562568 Effect.provideService(HostProcessEnvironment, {
563569 PATH: "C:\\Users\\tester\\AppData\\Roaming\\npm",
564570 PATHEXT: ".COM;.EXE;.BAT;.CMD",
......@@ -577,11 +583,58 @@ effectIt.layer(NodeServices.layer)("resolveSpawnCommand", (it) => {
577583 }),
578584 );
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
580630 it.effect("does not fall back to a shell for unresolved Windows commands", () =>
581631 Effect.gen(function* () {
582632 const command = yield* resolveSpawnCommand("missing & calc", ["unsafe & value"], {
583633 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
586639 expect(command).toEqual({
587640 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
559559 return yield* effect.pipe(Effect.provideService(PathDirectoryListings, listings));
560560 });
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
562574function cacheCommandResolution(
563575 cache: Map<string, CommandResolutionCacheEntry>,
564576 cacheKey: string,
......@@ -700,7 +712,32 @@ export const resolveSpawnCommand = Effect.fnUntraced(function* (
700712 ? { ...hostEnvironment, ...options.env }
701713 : options.env;
702714 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;
704741 const extension = NodePath.win32.extname(resolvedCommand).toLowerCase();
705742 if (extension !== ".cmd" && extension !== ".bat") {
706743 return { command: resolvedCommand, args: [...args], shell: false };