authorgravatar for bob@rjf.caBob Fowler <bob@rjf.ca> 2026-09-26 00:03:36-04:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-25 23:03:36-05:00
logdd8332da57355bccd7e666289267f2c94597debd
treecf48d755ac8f5048765aa9b24615be35497ca1a2
parenteeea71a88108c375c04853f58c298a518a54acaf
signature Signed by PGP key B5690EEEBB952194

fix(server): installed editors no longer vanish when discovery is slow (#13669)

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

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

apps/server/src/process/externalLauncher.ts+6-2
......@@ -20,7 +20,11 @@ import {
2020} from "@t3tools/contracts";
2121import { resolveEditorCommand } from "@t3tools/shared/editor";
2222import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
23import { isCommandAvailable, resolveSpawnCommand } from "@t3tools/shared/shell";
23import {
24 isCommandAvailable,
25 resolveSpawnCommand,
26 withPathDirectoryListings,
27} from "@t3tools/shared/shell";
2428import * as Clock from "effect/Clock";
2529import * as Config from "effect/Config";
2630import * as Context from "effect/Context";
......@@ -442,7 +446,7 @@ const resolveBrowserLaunch = Effect.fn("externalLauncher.resolveBrowserLaunch")(
442446const resolveAvailableEditors = Effect.fn("externalLauncher.resolveAvailableEditors")(function* () {
443447 const platform = yield* HostProcessPlatform;
444448 const env = { ...(yield* readBrowserLaunchEnv), ...(yield* readCommandLookupEnv) };
445 return yield* buildAvailableEditors(platform, env);
449 return yield* buildAvailableEditors(platform, env).pipe(withPathDirectoryListings);
446450});
447451
448452const resolveFileManagerRevealKind = Effect.fn("externalLauncher.resolveFileManagerRevealKind")(
packages/shared/src/shell.test.ts+35
......@@ -25,6 +25,7 @@ import {
2525 resolveWindowsEnvironment,
2626 SpawnExecutableResolution,
2727 WindowsShellEnvironment,
28 withPathDirectoryListings,
2829 type WindowsShellEnvironmentReader,
2930} from "./shell.ts";
3031
......@@ -473,6 +474,40 @@ effectIt.layer(NodeServices.layer)("resolveCommandPath", (it) => {
473474 expect(probed.filter((filePath) => /\.(com|exe|bat|cmd)$/.test(filePath))).toHaveLength(4);
474475 }),
475476 );
477
478 it.effect("probes only listed PATH names and relists a directory that changes", () =>
479 Effect.gen(function* () {
480 const fs = yield* FileSystem.FileSystem;
481 const path = yield* Path.Path;
482 const first = yield* fs.makeTempDirectoryScoped();
483 const second = yield* fs.makeTempDirectoryScoped();
484 yield* fs.writeFileString(path.join(first, "cursor.CMD"), "");
485 yield* fs.writeFileString(path.join(second, "cursor.EXE"), "");
486 const env = { PATH: `${first};${second}`, PATHEXT: ".EXE;.CMD" };
487 const probed: Array<string> = [];
488 yield* Effect.gen(function* () {
489 expect(yield* resolveCommandPath("cursor", { env })).toBe(path.join(first, "cursor.CMD"));
490 expect(yield* isCommandAvailable("absent", { env })).toBe(false);
491 yield* fs.writeFileString(path.join(second, "late.EXE"), "");
492 yield* fs.utimes(second, 4_102_444_800, 4_102_444_800); // seconds: 2100-01-01
493 expect(yield* resolveCommandPath("late", { env })).toBe(path.join(second, "late.EXE"));
494 }).pipe(
495 withPathDirectoryListings,
496 Effect.provideService(FileSystem.FileSystem, {
497 ...fs,
498 stat: (file) => {
499 // Record candidate probes, not the per-lookup directory mtime checks.
500 if (file !== first && file !== second) probed.push(file);
501 return fs.stat(file);
502 },
503 }),
504 );
505 expect(probed).toEqual([path.join(first, "cursor.CMD"), path.join(second, "late.EXE")]);
506 }).pipe(
507 Effect.provideService(HostProcessPlatform, "win32"),
508 Effect.provideService(CommandResolutionCache, new Map()),
509 ),
510 );
476511});
477512
478513effectIt.layer(NodeServices.layer)("resolveSpawnCommand", (it) => {
packages/shared/src/shell.ts+56
......@@ -3,10 +3,12 @@ import * as NodeOS from "node:os";
33import * as NodePath from "node:path";
44import * as NodeChildProcess from "node:child_process";
55import * as NodeFS from "node:fs";
6import * as Cache from "effect/Cache";
67import * as Clock from "effect/Clock";
78import * as Data from "effect/Data";
89import * as Effect from "effect/Effect";
910import * as FileSystem from "effect/FileSystem";
11import * as Option from "effect/Option";
1012import * as Path from "effect/Path";
1113
1214import { HostProcessEnvironment, HostProcessPlatform } from "./hostProcess.ts";
......@@ -511,6 +513,52 @@ export const CommandResolutionCache = Context.Reference<Map<string, CommandResol
511513 },
512514);
513515
516interface PathDirectoryListing {
517 readonly modified: number | null | undefined;
518 readonly names: ReadonlySet<string> | undefined;
519}
520
521// mtime of a PATH directory; null when missing, undefined when unreadable.
522const directoryMtime = (directory: string) =>
523 FileSystem.FileSystem.use((fileSystem) => fileSystem.stat(directory)).pipe(
524 Effect.map((info) =>
525 info.type === "Directory" ? Option.getOrUndefined(info.mtime)?.getTime() : undefined,
526 ),
527 Effect.catch((error) => Effect.succeed(error.reason._tag === "NotFound" ? null : undefined)),
528 );
529
530// Dated before it is read, so an entry added in between changes the mtime seen
531// on the next check. Without names, lookups probe candidates directly.
532const listPathDirectory = Effect.fnUntraced(function* (
533 directory: string,
534): Effect.fn.Return<PathDirectoryListing, never, FileSystem.FileSystem> {
535 const modified = yield* directoryMtime(directory);
536 if (modified == null) return { modified, names: modified === null ? new Set() : undefined };
537 const entries = yield* FileSystem.FileSystem.use((fileSystem) =>
538 fileSystem.readDirectory(directory),
539 ).pipe(Effect.orElseSucceed(() => undefined));
540 return { modified, names: entries && new Set(entries.map((entry) => entry.toLowerCase())) };
541});
542
543const PathDirectoryListings = Context.Reference<
544 Cache.Cache<string, PathDirectoryListing, never, FileSystem.FileSystem> | undefined
545>("@t3tools/shared/shell/PathDirectoryListings", { defaultValue: () => undefined });
546
547/**
548 * Run a batch of command lookups (e.g. editor discovery) that lists each PATH
549 * directory once, relisting it if its mtime changes, and probes only listed
550 * names instead of every PATH x PATHEXT candidate per command.
551 */
552export const withPathDirectoryListings = <A, E, R>(effect: Effect.Effect<A, E, R>) =>
553 Effect.gen(function* () {
554 const listings = yield* Cache.make({
555 capacity: 1024,
556 lookup: listPathDirectory,
557 requireServicesAt: "lookup",
558 });
559 return yield* effect.pipe(Effect.provideService(PathDirectoryListings, listings));
560 });
561
514562function cacheCommandResolution(
515563 cache: Map<string, CommandResolutionCacheEntry>,
516564 cacheKey: string,
......@@ -602,8 +650,16 @@ const resolveCommandPathForPlatform = Effect.fn("shell.resolveCommandPathForPlat
602650 pathEntries.push(pathEntry);
603651 }
604652
653 const listings = yield* PathDirectoryListings;
605654 for (const pathEntry of pathEntries) {
655 let listing = listings && (yield* Cache.get(listings, pathEntry));
656 if (listings && listing?.names && listing.modified !== (yield* directoryMtime(pathEntry))) {
657 yield* Cache.invalidate(listings, pathEntry);
658 listing = yield* Cache.get(listings, pathEntry);
659 }
606660 for (const candidate of commandCandidates) {
661 // The stat below still checks exact case and rejects non-files.
662 if (listing?.names && !listing.names.has(candidate.toLowerCase())) continue;
607663 const candidatePath = path.join(pathEntry, candidate);
608664 if (yield* isExecutableFile(candidatePath, platform, windowsPathExtensions)) {
609665 cacheCommandResolution(cache, cacheKey, candidatePath, nowNanos);