diff --git a/apps/server/src/process/externalLauncher.ts b/apps/server/src/process/externalLauncher.ts index 29c25e790c61..ab287e4d78ee 100644 --- a/apps/server/src/process/externalLauncher.ts +++ b/apps/server/src/process/externalLauncher.ts @@ -20,7 +20,11 @@ import { } from "@t3tools/contracts"; import { resolveEditorCommand } from "@t3tools/shared/editor"; import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; -import { isCommandAvailable, resolveSpawnCommand } from "@t3tools/shared/shell"; +import { + isCommandAvailable, + resolveSpawnCommand, + withPathDirectoryListings, +} from "@t3tools/shared/shell"; import * as Clock from "effect/Clock"; import * as Config from "effect/Config"; import * as Context from "effect/Context"; @@ -442,7 +446,7 @@ const resolveBrowserLaunch = Effect.fn("externalLauncher.resolveBrowserLaunch")( const resolveAvailableEditors = Effect.fn("externalLauncher.resolveAvailableEditors")(function* () { const platform = yield* HostProcessPlatform; const env = { ...(yield* readBrowserLaunchEnv), ...(yield* readCommandLookupEnv) }; - return yield* buildAvailableEditors(platform, env); + return yield* buildAvailableEditors(platform, env).pipe(withPathDirectoryListings); }); const resolveFileManagerRevealKind = Effect.fn("externalLauncher.resolveFileManagerRevealKind")( diff --git a/packages/shared/src/shell.test.ts b/packages/shared/src/shell.test.ts index 621fe49b3087..6c89e49d017f 100644 --- a/packages/shared/src/shell.test.ts +++ b/packages/shared/src/shell.test.ts @@ -25,6 +25,7 @@ import { resolveWindowsEnvironment, SpawnExecutableResolution, WindowsShellEnvironment, + withPathDirectoryListings, type WindowsShellEnvironmentReader, } from "./shell.ts"; @@ -473,6 +474,40 @@ effectIt.layer(NodeServices.layer)("resolveCommandPath", (it) => { expect(probed.filter((filePath) => /\.(com|exe|bat|cmd)$/.test(filePath))).toHaveLength(4); }), ); + + it.effect("probes only listed PATH names and relists a directory that changes", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const first = yield* fs.makeTempDirectoryScoped(); + const second = yield* fs.makeTempDirectoryScoped(); + yield* fs.writeFileString(path.join(first, "cursor.CMD"), ""); + yield* fs.writeFileString(path.join(second, "cursor.EXE"), ""); + const env = { PATH: `${first};${second}`, PATHEXT: ".EXE;.CMD" }; + const probed: Array = []; + yield* Effect.gen(function* () { + expect(yield* resolveCommandPath("cursor", { env })).toBe(path.join(first, "cursor.CMD")); + expect(yield* isCommandAvailable("absent", { env })).toBe(false); + yield* fs.writeFileString(path.join(second, "late.EXE"), ""); + yield* fs.utimes(second, 4_102_444_800, 4_102_444_800); // seconds: 2100-01-01 + expect(yield* resolveCommandPath("late", { env })).toBe(path.join(second, "late.EXE")); + }).pipe( + withPathDirectoryListings, + Effect.provideService(FileSystem.FileSystem, { + ...fs, + stat: (file) => { + // Record candidate probes, not the per-lookup directory mtime checks. + if (file !== first && file !== second) probed.push(file); + return fs.stat(file); + }, + }), + ); + expect(probed).toEqual([path.join(first, "cursor.CMD"), path.join(second, "late.EXE")]); + }).pipe( + Effect.provideService(HostProcessPlatform, "win32"), + Effect.provideService(CommandResolutionCache, new Map()), + ), + ); }); effectIt.layer(NodeServices.layer)("resolveSpawnCommand", (it) => { diff --git a/packages/shared/src/shell.ts b/packages/shared/src/shell.ts index 11a45907cc1d..0a25785d916d 100644 --- a/packages/shared/src/shell.ts +++ b/packages/shared/src/shell.ts @@ -3,10 +3,12 @@ import * as NodeOS from "node:os"; import * as NodePath from "node:path"; import * as NodeChildProcess from "node:child_process"; import * as NodeFS from "node:fs"; +import * as Cache from "effect/Cache"; import * as Clock from "effect/Clock"; import * as Data from "effect/Data"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; +import * as Option from "effect/Option"; import * as Path from "effect/Path"; import { HostProcessEnvironment, HostProcessPlatform } from "./hostProcess.ts"; @@ -511,6 +513,52 @@ export const CommandResolutionCache = Context.Reference | undefined; +} + +// mtime of a PATH directory; null when missing, undefined when unreadable. +const directoryMtime = (directory: string) => + FileSystem.FileSystem.use((fileSystem) => fileSystem.stat(directory)).pipe( + Effect.map((info) => + info.type === "Directory" ? Option.getOrUndefined(info.mtime)?.getTime() : undefined, + ), + Effect.catch((error) => Effect.succeed(error.reason._tag === "NotFound" ? null : undefined)), + ); + +// Dated before it is read, so an entry added in between changes the mtime seen +// on the next check. Without names, lookups probe candidates directly. +const listPathDirectory = Effect.fnUntraced(function* ( + directory: string, +): Effect.fn.Return { + const modified = yield* directoryMtime(directory); + if (modified == null) return { modified, names: modified === null ? new Set() : undefined }; + const entries = yield* FileSystem.FileSystem.use((fileSystem) => + fileSystem.readDirectory(directory), + ).pipe(Effect.orElseSucceed(() => undefined)); + return { modified, names: entries && new Set(entries.map((entry) => entry.toLowerCase())) }; +}); + +const PathDirectoryListings = Context.Reference< + Cache.Cache | undefined +>("@t3tools/shared/shell/PathDirectoryListings", { defaultValue: () => undefined }); + +/** + * Run a batch of command lookups (e.g. editor discovery) that lists each PATH + * directory once, relisting it if its mtime changes, and probes only listed + * names instead of every PATH x PATHEXT candidate per command. + */ +export const withPathDirectoryListings = (effect: Effect.Effect) => + Effect.gen(function* () { + const listings = yield* Cache.make({ + capacity: 1024, + lookup: listPathDirectory, + requireServicesAt: "lookup", + }); + return yield* effect.pipe(Effect.provideService(PathDirectoryListings, listings)); + }); + function cacheCommandResolution( cache: Map, cacheKey: string, @@ -602,8 +650,16 @@ const resolveCommandPathForPlatform = Effect.fn("shell.resolveCommandPathForPlat pathEntries.push(pathEntry); } + const listings = yield* PathDirectoryListings; for (const pathEntry of pathEntries) { + let listing = listings && (yield* Cache.get(listings, pathEntry)); + if (listings && listing?.names && listing.modified !== (yield* directoryMtime(pathEntry))) { + yield* Cache.invalidate(listings, pathEntry); + listing = yield* Cache.get(listings, pathEntry); + } for (const candidate of commandCandidates) { + // The stat below still checks exact case and rejects non-files. + if (listing?.names && !listing.names.has(candidate.toLowerCase())) continue; const candidatePath = path.join(pathEntry, candidate); if (yield* isExecutableFile(candidatePath, platform, windowsPathExtensions)) { cacheCommandResolution(cache, cacheKey, candidatePath, nowNanos);