Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions apps/server/src/process/externalLauncher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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")(
Expand Down
35 changes: 35 additions & 0 deletions packages/shared/src/shell.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import {
resolveWindowsEnvironment,
SpawnExecutableResolution,
WindowsShellEnvironment,
withPathDirectoryListings,
type WindowsShellEnvironmentReader,
} from "./shell.ts";

Expand Down Expand Up @@ -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<string> = [];
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) => {
Expand Down
56 changes: 56 additions & 0 deletions packages/shared/src/shell.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -511,6 +513,52 @@ export const CommandResolutionCache = Context.Reference<Map<string, CommandResol
},
);

interface PathDirectoryListing {
readonly modified: number | null | undefined;
readonly names: ReadonlySet<string> | 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<PathDirectoryListing, never, FileSystem.FileSystem> {
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<string, PathDirectoryListing, never, FileSystem.FileSystem> | 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 = <A, E, R>(effect: Effect.Effect<A, E, R>) =>
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<string, CommandResolutionCacheEntry>,
cacheKey: string,
Expand Down Expand Up @@ -602,8 +650,16 @@ const resolveCommandPathForPlatform = Effect.fn("shell.resolveCommandPathForPlat
pathEntries.push(pathEntry);
}

const listings = yield* PathDirectoryListings;
Comment thread
bfowler marked this conversation as resolved.
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);
Expand Down
Loading