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 {...@@ -20,7 +20,11 @@ import {
20} from "@t3tools/contracts";20} from "@t3tools/contracts";
21import { resolveEditorCommand } from "@t3tools/shared/editor";21import { resolveEditorCommand } from "@t3tools/shared/editor";
22import { HostProcessPlatform } from "@t3tools/shared/hostProcess";22import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
23import { isCommandAvailable, resolveSpawnCommand } from "@t3tools/shared/shell";23import {
24 isCommandAvailable,
25 resolveSpawnCommand,
26 withPathDirectoryListings,
27} from "@t3tools/shared/shell";
24import * as Clock from "effect/Clock";28import * as Clock from "effect/Clock";
25import * as Config from "effect/Config";29import * as Config from "effect/Config";
26import * as Context from "effect/Context";30import * as Context from "effect/Context";
...@@ -442,7 +446,7 @@ const resolveBrowserLaunch = Effect.fn("externalLauncher.resolveBrowserLaunch")(...@@ -442,7 +446,7 @@ const resolveBrowserLaunch = Effect.fn("externalLauncher.resolveBrowserLaunch")(
442const resolveAvailableEditors = Effect.fn("externalLauncher.resolveAvailableEditors")(function* () {446const resolveAvailableEditors = Effect.fn("externalLauncher.resolveAvailableEditors")(function* () {
443 const platform = yield* HostProcessPlatform;447 const platform = yield* HostProcessPlatform;
444 const env = { ...(yield* readBrowserLaunchEnv), ...(yield* readCommandLookupEnv) };448 const env = { ...(yield* readBrowserLaunchEnv), ...(yield* readCommandLookupEnv) };
445 return yield* buildAvailableEditors(platform, env);449 return yield* buildAvailableEditors(platform, env).pipe(withPathDirectoryListings);
446});450});
447451
448const resolveFileManagerRevealKind = Effect.fn("externalLauncher.resolveFileManagerRevealKind")(452const resolveFileManagerRevealKind = Effect.fn("externalLauncher.resolveFileManagerRevealKind")(
packages/shared/src/shell.test.ts+35
...@@ -25,6 +25,7 @@ import {...@@ -25,6 +25,7 @@ import {
25 resolveWindowsEnvironment,25 resolveWindowsEnvironment,
26 SpawnExecutableResolution,26 SpawnExecutableResolution,
27 WindowsShellEnvironment,27 WindowsShellEnvironment,
28 withPathDirectoryListings,
28 type WindowsShellEnvironmentReader,29 type WindowsShellEnvironmentReader,
29} from "./shell.ts";30} from "./shell.ts";
3031
...@@ -473,6 +474,40 @@ effectIt.layer(NodeServices.layer)("resolveCommandPath", (it) => {...@@ -473,6 +474,40 @@ effectIt.layer(NodeServices.layer)("resolveCommandPath", (it) => {
473 expect(probed.filter((filePath) => /\.(com|exe|bat|cmd)$/.test(filePath))).toHaveLength(4);474 expect(probed.filter((filePath) => /\.(com|exe|bat|cmd)$/.test(filePath))).toHaveLength(4);
474 }),475 }),
475 );476 );
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 );
476});511});
477512
478effectIt.layer(NodeServices.layer)("resolveSpawnCommand", (it) => {513effectIt.layer(NodeServices.layer)("resolveSpawnCommand", (it) => {
packages/shared/src/shell.ts+56
...@@ -3,10 +3,12 @@ import * as NodeOS from "node:os";...@@ -3,10 +3,12 @@ import * as NodeOS from "node:os";
3import * as NodePath from "node:path";3import * as NodePath from "node:path";
4import * as NodeChildProcess from "node:child_process";4import * as NodeChildProcess from "node:child_process";
5import * as NodeFS from "node:fs";5import * as NodeFS from "node:fs";
6import * as Cache from "effect/Cache";
6import * as Clock from "effect/Clock";7import * as Clock from "effect/Clock";
7import * as Data from "effect/Data";8import * as Data from "effect/Data";
8import * as Effect from "effect/Effect";9import * as Effect from "effect/Effect";
9import * as FileSystem from "effect/FileSystem";10import * as FileSystem from "effect/FileSystem";
11import * as Option from "effect/Option";
10import * as Path from "effect/Path";12import * as Path from "effect/Path";
1113
12import { HostProcessEnvironment, HostProcessPlatform } from "./hostProcess.ts";14import { HostProcessEnvironment, HostProcessPlatform } from "./hostProcess.ts";
...@@ -511,6 +513,52 @@ export const CommandResolutionCache = Context.Reference<Map<string, CommandResol...@@ -511,6 +513,52 @@ export const CommandResolutionCache = Context.Reference<Map<string, CommandResol
511 },513 },
512);514);
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
514function cacheCommandResolution(562function cacheCommandResolution(
515 cache: Map<string, CommandResolutionCacheEntry>,563 cache: Map<string, CommandResolutionCacheEntry>,
516 cacheKey: string,564 cacheKey: string,
...@@ -602,8 +650,16 @@ const resolveCommandPathForPlatform = Effect.fn("shell.resolveCommandPathForPlat...@@ -602,8 +650,16 @@ const resolveCommandPathForPlatform = Effect.fn("shell.resolveCommandPathForPlat
602 pathEntries.push(pathEntry);650 pathEntries.push(pathEntry);
603 }651 }
604652
653 const listings = yield* PathDirectoryListings;
605 for (const pathEntry of pathEntries) {654 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 }
606 for (const candidate of commandCandidates) {660 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;
607 const candidatePath = path.join(pathEntry, candidate);663 const candidatePath = path.join(pathEntry, candidate);
608 if (yield* isExecutableFile(candidatePath, platform, windowsPathExtensions)) {664 if (yield* isExecutableFile(candidatePath, platform, windowsPathExtensions)) {
609 cacheCommandResolution(cache, cacheKey, candidatePath, nowNanos);665 cacheCommandResolution(cache, cacheKey, candidatePath, nowNanos);