| author | |
| committer | |
| log | dd8332da57355bccd7e666289267f2c94597debd |
| tree | cf48d755ac8f5048765aa9b24615be35497ca1a2 |
| parent | eeea71a88108c375c04853f58c298a518a54acaf |
| signature | Signed by PGP key B5690EEEBB952194 |
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"; |
| 21 | import { resolveEditorCommand } from "@t3tools/shared/editor"; | 21 | import { resolveEditorCommand } from "@t3tools/shared/editor"; |
| 22 | import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; | 22 | import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; |
| 23 | import { isCommandAvailable, resolveSpawnCommand } from "@t3tools/shared/shell"; | 23 | import { |
| 24 | isCommandAvailable, | ||
| 25 | resolveSpawnCommand, | ||
| 26 | withPathDirectoryListings, | ||
| 27 | } from "@t3tools/shared/shell"; | ||
| 24 | import * as Clock from "effect/Clock"; | 28 | import * as Clock from "effect/Clock"; |
| 25 | import * as Config from "effect/Config"; | 29 | import * as Config from "effect/Config"; |
| 26 | import * as Context from "effect/Context"; | 30 | import * 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")( |
| 442 | const resolveAvailableEditors = Effect.fn("externalLauncher.resolveAvailableEditors")(function* () { | 446 | const 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 | }); |
| 447 | 451 | ||
| 448 | const resolveFileManagerRevealKind = Effect.fn("externalLauncher.resolveFileManagerRevealKind")( | 452 | const 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"; |
| 30 | 31 | ||
| ... | @@ -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 | }); |
| 477 | 512 | ||
| 478 | effectIt.layer(NodeServices.layer)("resolveSpawnCommand", (it) => { | 513 | effectIt.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"; |
| 3 | import * as NodePath from "node:path"; | 3 | import * as NodePath from "node:path"; |
| 4 | import * as NodeChildProcess from "node:child_process"; | 4 | import * as NodeChildProcess from "node:child_process"; |
| 5 | import * as NodeFS from "node:fs"; | 5 | import * as NodeFS from "node:fs"; |
| 6 | import * as Cache from "effect/Cache"; | ||
| 6 | import * as Clock from "effect/Clock"; | 7 | import * as Clock from "effect/Clock"; |
| 7 | import * as Data from "effect/Data"; | 8 | import * as Data from "effect/Data"; |
| 8 | import * as Effect from "effect/Effect"; | 9 | import * as Effect from "effect/Effect"; |
| 9 | import * as FileSystem from "effect/FileSystem"; | 10 | import * as FileSystem from "effect/FileSystem"; |
| 11 | import * as Option from "effect/Option"; | ||
| 10 | import * as Path from "effect/Path"; | 12 | import * as Path from "effect/Path"; |
| 11 | 13 | ||
| 12 | import { HostProcessEnvironment, HostProcessPlatform } from "./hostProcess.ts"; | 14 | import { 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 | ); |
| 513 | 515 | ||
| 516 | interface 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. | ||
| 522 | const 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. | ||
| 532 | const 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 | |||
| 543 | const 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 | */ | ||
| 552 | export 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 | |||
| 514 | function cacheCommandResolution( | 562 | function 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 | } |
| 604 | 652 | ||
| 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); |