Skip to content
Closed
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
18 changes: 18 additions & 0 deletions apps/server/src/process/externalLauncher.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import * as Fiber from "effect/Fiber";
import * as FileSystem from "effect/FileSystem";
import * as Layer from "effect/Layer";
import * as Path from "effect/Path";
import * as PlatformError from "effect/PlatformError";
import * as Sink from "effect/Sink";
import * as Stream from "effect/Stream";
import * as TestClock from "effect/testing/TestClock";
Expand Down Expand Up @@ -835,11 +836,17 @@ it.effect("discovers editors through the service API", () =>

it.effect("memoizes editor discovery and refreshes after the cache window", () => {
let statCalls = 0;
let directoryCalls = 0;
const fileInfo = { type: "File" } as FileSystem.File.Info;
const launcherLayer = ExternalLauncher.layer.pipe(
Layer.provide(
Layer.mergeAll(
FileSystem.layerNoop({
readDirectory: () =>
Effect.sync(() => {
directoryCalls += 1;
return ["code.eXe", "cursor.EXE"];
}),
stat: () =>
Effect.sync(() => {
statCalls += 1;
Expand All @@ -860,6 +867,7 @@ it.effect("memoizes editor discovery and refreshes after the cache window", () =

const first = yield* launcher.resolveAvailableEditors();
assert.equal(first.includes("vscode"), true);
assert.equal(directoryCalls, 1);
const statCallsAfterFirstScan = statCalls;
assert.isAbove(statCallsAfterFirstScan, 0);

Expand All @@ -869,11 +877,13 @@ it.effect("memoizes editor discovery and refreshes after the cache window", () =
const second = yield* launcher.resolveAvailableEditors();
assert.deepEqual([...second], [...first]);
assert.equal(statCalls, statCallsAfterFirstScan);
assert.equal(directoryCalls, 1);

// Past the discovery cache window the next call rescans.
yield* TestClock.adjust("30 seconds");
yield* launcher.resolveAvailableEditors();
assert.isAbove(statCalls, statCallsAfterFirstScan);
assert.equal(directoryCalls, 2);
}).pipe(
Effect.provide(
Layer.mergeAll(
Expand Down Expand Up @@ -905,6 +915,14 @@ it.effect("rescans after an interrupted discovery instead of caching the interru
Layer.provide(
Layer.mergeAll(
FileSystem.layerNoop({
readDirectory: () =>
Effect.fail(
PlatformError.systemError({
_tag: "PermissionDenied",
module: "FileSystem",
method: "readDirectory",
}),
),
// The first scan parks inside `stat` so the interrupt lands while
// discovery is in flight, which is what a client disconnecting
// mid-connect does to the shared effect.
Expand Down
9 changes: 7 additions & 2 deletions apps/server/src/process/externalLauncher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,11 @@ import {
type LaunchEditorInput,
} from "@t3tools/contracts";
import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
import { isCommandAvailable, resolveSpawnCommand } from "@t3tools/shared/shell";
import {
isCommandAvailable,
resolveSpawnCommand,
withCommandDirectoryCache,
} 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 @@ -456,7 +460,8 @@ 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);
const discovery = buildAvailableEditors(platform, env);
return yield* platform === "win32" ? withCommandDirectoryCache(discovery) : discovery;
});

const resolveFileManagerRevealKind = Effect.fn("externalLauncher.resolveFileManagerRevealKind")(
Expand Down
92 changes: 92 additions & 0 deletions apps/server/src/processRunner.cleanup.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
// @effect-diagnostics nodeBuiltinImport:off - observes the native cleanup commands used by the process spawner.
import type * as NodeChildProcess from "node:child_process";
import * as NodeModule from "node:module";
import * as NodeServices from "@effect/platform-node/NodeServices";
import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
import { expect, it } from "@effect/vitest";
import { afterEach, beforeEach, vi } from "vite-plus/test";
import * as Deferred from "effect/Deferred";
import * as Effect from "effect/Effect";
import * as Fiber from "effect/Fiber";
import * as Option from "effect/Option";
import * as Stream from "effect/Stream";
import { TestClock } from "effect/testing";
import * as ChildProcess from "effect/unstable/process/ChildProcess";
import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner";

import * as ProcessRunner from "./processRunner.ts";

// Observe native ESM consumers as well as modules loaded through the test runner.
const nativeChildProcess = NodeModule.createRequire(import.meta.url)(
"node:child_process",
) as typeof NodeChildProcess;

beforeEach(() => {
vi.spyOn(nativeChildProcess, "exec");
NodeModule.syncBuiltinESMExports();
});
afterEach(() => {
vi.restoreAllMocks();
NodeModule.syncBuiltinESMExports();
});

const taskkillCalls = () =>
vi
.mocked(nativeChildProcess.exec)
.mock.calls.filter(([command]) => command.startsWith("taskkill "));

it.layer(NodeServices.layer)("completed probe cleanup", (it) => {
for (const cleanupOnExit of [false, true] as const) {
it.effect(`preserves nonzero results with cleanupOnExit=${cleanupOnExit}`, () =>
Effect.gen(function* () {
const runner = yield* ProcessRunner.ProcessRunner;
const result = yield* runner.run({
command: process.execPath,
args: ["-e", "process.stdout.write('probe result'); process.exitCode = 1"],
...(cleanupOnExit ? {} : { windowsCleanupOnExit: cleanupOnExit }),
});
expect(result.code).toBe(1);
expect(result.stdout).toBe("probe result");
if ((yield* HostProcessPlatform) === "win32") {
expect(taskkillCalls()).toHaveLength(cleanupOnExit ? 2 : 0);
}
}).pipe(Effect.provide(ProcessRunner.layer)),
);
}

for (const termination of ["interrupt", "timeout"] as const) {
it.effect(`still kills running probes on ${termination}`, () =>
Effect.gen(function* () {
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
const ready = yield* Deferred.make<ChildProcessSpawner.ChildProcessHandle>();
const probe = Effect.gen(function* () {
const child = yield* spawner.spawn(
ChildProcess.make(
process.execPath,
["-e", "process.stdout.write('ready'); setInterval(() => {}, 1000)"],
{ cleanupOnExit: false },
),
);
yield* Stream.runHead(child.stdout);
yield* Deferred.succeed(ready, child);
return yield* Effect.never;
}).pipe(Effect.scoped);
const fiber = yield* probe.pipe(Effect.timeoutOption("1 second"), Effect.forkChild);
const child = yield* Deferred.await(ready);
expect(yield* child.isRunning).toBe(true);

if (termination === "interrupt") {
yield* Fiber.interrupt(fiber);
} else {
yield* TestClock.adjust("1 second");
expect(Option.isNone(yield* Fiber.join(fiber))).toBe(true);
}

expect(yield* child.isRunning).toBe(false);
if ((yield* HostProcessPlatform) === "win32") {
expect(taskkillCalls()).toHaveLength(1);
}
}),
);
}
});
21 changes: 21 additions & 0 deletions apps/server/src/processRunner.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ type ChildProcessCommand = {
readonly args: ReadonlyArray<string>;
readonly options: {
readonly shell?: boolean | string;
readonly cleanupOnExit?: boolean;
};
};

Expand Down Expand Up @@ -80,6 +81,26 @@ const runWith =
);

describe("runProcess", () => {
for (const platform of ["win32", "linux", "darwin"] as const) {
it.effect(`only applies Windows probe cleanup settings on Windows (${platform})`, () =>
Effect.gen(function* () {
const spawner = makeSpawner((command) => {
expect(command.options.cleanupOnExit).toBe(platform === "win32" ? false : undefined);
return Effect.succeed(makeHandle({ code: 1 }));
});
const result = yield* runWith(spawner)({
command: "git",
args: ["rev-parse", "--show-toplevel"],
windowsCleanupOnExit: false,
});
expect(result.code).toBe(1);
}).pipe(
Effect.provideService(HostProcessPlatform, platform),
Effect.provideService(SpawnExecutableResolution, () => "C:\\git.exe"),
),
);
}

it.effect("collects stdout through an injected ChildProcessSpawner", () =>
Effect.gen(function* () {
const spawner = makeSpawner((command) =>
Expand Down
4 changes: 4 additions & 0 deletions apps/server/src/processRunner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ export interface ProcessRunInput {
readonly maxOutputBytes?: number | undefined;
readonly outputMode?: "error" | "truncate" | undefined;
readonly truncatedMarker?: string | undefined;
/** On Windows, disable only for leaf probes that cannot leave child processes after exiting. */
readonly windowsCleanupOnExit?: boolean | undefined;
/**
* On timeout, return a synthetic timedOut result.
* Partial stdout/stderr are not preserved.
Expand Down Expand Up @@ -294,6 +296,7 @@ const runProcessCore = Effect.fn("processRunner.runProcessCore")(function* (
const outputMode = input.outputMode ?? "error";
const truncatedMarker = input.truncatedMarker ?? "";
const extendEnv = input.env !== undefined;
const platform = yield* HostProcessPlatform;
const spawnCommand = yield* resolveSpawnCommand(
input.command,
input.args,
Expand All @@ -311,6 +314,7 @@ const runProcessCore = Effect.fn("processRunner.runProcessCore")(function* (
}
: {}),
shell: spawnCommand.shell,
...(platform === "win32" ? { cleanupOnExit: input.windowsCleanupOnExit } : {}),
}),
)
.pipe(
Expand Down
61 changes: 58 additions & 3 deletions apps/server/src/project/RepositoryIdentityResolver.test.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
// @effect-diagnostics nodeBuiltinImport:off - realpathSync.native resolves Windows 8.3 short names, which the Effect realPath does not.
import * as NodeFS from "node:fs";
import * as NodeServices from "@effect/platform-node/NodeServices";
import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
import { expect, it } from "@effect/vitest";
import * as Duration from "effect/Duration";
import * as Effect from "effect/Effect";
import * as FileSystem from "effect/FileSystem";
import * as Layer from "effect/Layer";
import * as Path from "effect/Path";
import * as PlatformError from "effect/PlatformError";
import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner";
import { TestClock } from "effect/testing";

Expand Down Expand Up @@ -38,11 +40,12 @@ const makeRepositoryIdentityResolverTestLayer = (options: {
).pipe(Layer.provide(ProcessRunner.layer));

it.layer(NodeServices.layer)("RepositoryIdentityResolverLive", (it) => {
it.effect("reuses the cached Git root for repeated workspace lookups", () => {
it.effect("reuses the cached Git root even when the filesystem preflight fails", () => {
const calls: Array<ReadonlyArray<string>> = [];
const processRunner = Layer.succeed(ProcessRunner.ProcessRunner, {
run: (input) =>
Effect.sync(() => {
expect(input.windowsCleanupOnExit).toBe(false);
calls.push(input.args);
return {
stdout: input.args.includes("rev-parse")
Expand All @@ -61,7 +64,22 @@ it.layer(NodeServices.layer)("RepositoryIdentityResolverLive", (it) => {
const resolverLayer = Layer.effect(
RepositoryIdentityResolver.RepositoryIdentityResolver,
RepositoryIdentityResolver.make(),
).pipe(Layer.provide(processRunner));
).pipe(
Layer.provide(processRunner),
Layer.provide(
FileSystem.layerNoop({
exists: (path) =>
Effect.fail(
PlatformError.systemError({
_tag: "PermissionDenied",
module: "FileSystem",
method: "exists",
pathOrDescriptor: path,
}),
),
}),
),
);

return Effect.gen(function* () {
const resolver = yield* RepositoryIdentityResolver.RepositoryIdentityResolver;
Expand Down Expand Up @@ -105,7 +123,10 @@ it.layer(NodeServices.layer)("RepositoryIdentityResolverLive", (it) => {
const resolverLayer = Layer.effect(
RepositoryIdentityResolver.RepositoryIdentityResolver,
RepositoryIdentityResolver.make(),
).pipe(Layer.provide(processRunner));
).pipe(
Layer.provide(processRunner),
Layer.provide(FileSystem.layerNoop({ exists: () => Effect.succeed(true) })),
);

return Effect.gen(function* () {
const resolver = yield* RepositoryIdentityResolver.RepositoryIdentityResolver;
Expand All @@ -121,6 +142,40 @@ it.layer(NodeServices.layer)("RepositoryIdentityResolverLive", (it) => {
}).pipe(Effect.provide(resolverLayer));
});

it.effect("skips missing workspaces and discovers them immediately when restored", () =>
Effect.gen(function* () {
const fileSystem = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const parent = yield* fileSystem.makeTempDirectoryScoped({
prefix: "t3-repository-identity-restored-",
});
const cwd = path.join(parent, "restored");
const processRunner = yield* ProcessRunner.ProcessRunner;
const calls: Array<ReadonlyArray<string>> = [];
const resolver = yield* RepositoryIdentityResolver.make().pipe(
Effect.provideService(ProcessRunner.ProcessRunner, {
run: (input) => {
calls.push(input.args);
return processRunner.run(input);
},
}),
);

expect(yield* resolver.resolve(cwd)).toBeNull();
expect(yield* resolver.resolve(cwd)).toBeNull();
const platform = yield* HostProcessPlatform;
expect(calls).toHaveLength(platform === "win32" ? 0 : 2);
calls.length = 0;

yield* fileSystem.makeDirectory(cwd);
yield* git(cwd, ["init"]);
yield* git(cwd, ["remote", "add", "origin", "git@github.com:T3Tools/t3code.git"]);

expect((yield* resolver.resolve(cwd))?.canonicalKey).toBe("github.com/t3tools/t3code");
expect(calls).toHaveLength(2);
}).pipe(Effect.provide(ProcessRunner.layer)),
);

it.effect("normalizes equivalent GitHub remotes into a stable repository identity", () =>
Effect.gen(function* () {
const fileSystem = yield* FileSystem.FileSystem;
Expand Down
Loading
Loading