Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
44 commits
Select commit Hold shift + click to select a range
f828f8b
fix(server): isolate Linux process launches from the RPC event loop
voltcrash Nov 3, 2026
84356c0
fix(server): keep hosted commands alive when another launch fails
voltcrash Nov 3, 2026
9ad9e21
chore: merge main into linux process launch fix
maria-rcks Nov 4, 2026
9642d50
test(server): decode spawn helper file paths
maria-rcks Nov 4, 2026
1e017bf
Merge remote-tracking branch 'github/main' into voltcrash/address-iss…
maria-rcks Nov 5, 2026
462ad48
Merge branch 'pr-30209' into t3code/windows-spawn-off-main
SunkenInTime Nov 6, 2026
a9f555d
perf(server): Windows process launches no longer block the server thread
SunkenInTime Nov 7, 2026
b1f00f3
fix(server): a dead launcher thread ends its children's output and pr…
SunkenInTime Nov 7, 2026
03f4998
chore: merge main into windows process launch fix
maria-rcks Nov 7, 2026
9139b35
fix(server): report a dead launcher's child exit only after taskkill …
maria-rcks Nov 7, 2026
93556fe
fix(server): keep the launcher alive when a spawn hits descriptor exh…
maria-rcks Nov 7, 2026
a9549c1
refactor(server): drop the Linux launcher so this PR changes Windows …
SunkenInTime Nov 7, 2026
0ac0733
fix(server): a request for a child whose launch failed early no longe…
SunkenInTime Nov 7, 2026
0caa12d
fix(server): a caller that drops a child's pipe closes it in the laun…
SunkenInTime Nov 7, 2026
fc010c9
test(server): the server's spawner runs Windows children on a launche…
SunkenInTime Nov 7, 2026
fc986f5
fix(server): every server launch on Windows reaches the launcher threads
SunkenInTime Nov 7, 2026
6877d5e
docs(server): say a dead launcher's cleanup tries to end the tree
SunkenInTime Nov 7, 2026
2004169
fix(server): launched children get the server's current environment, …
SunkenInTime Nov 7, 2026
99df531
Merge remote-tracking branch 'github/main' into t3code/windows-spawn-…
maria-rcks Nov 10, 2026
f09ade1
Merge remote-tracking branch 'origin/main' into t3code/windows-spawn-…
juliusmarminge Nov 11, 2026
f69a062
Merge remote-tracking branch 'origin/main' into t3code/windows-spawn-…
juliusmarminge Nov 12, 2026
a2c6a78
chore: keep patched dependencies sorted in the lockfile
juliusmarminge Nov 12, 2026
0e0ba64
Merge remote-tracking branch 'origin/main' into t3code/windows-spawn-…
juliusmarminge Nov 12, 2026
44ef529
Merge remote-tracking branch 'origin/main' into t3code/windows-spawn-…
juliusmarminge Nov 12, 2026
7ffecd5
Merge remote-tracking branch 'origin/main' into t3code/windows-spawn-…
juliusmarminge Nov 12, 2026
20fc938
Merge remote-tracking branch 'origin/main' into t3code/windows-spawn-…
juliusmarminge Nov 12, 2026
036b0b2
style(server): group the spawner import with the other process imports
juliusmarminge Nov 12, 2026
48fb540
Merge remote-tracking branch 'origin/main' into t3code/windows-spawn-…
juliusmarminge Nov 13, 2026
3957530
Merge remote-tracking branch 'origin/main' into t3code/windows-spawn-…
juliusmarminge Nov 14, 2026
0ce9e61
Merge remote-tracking branch 'origin/main' into t3code/windows-spawn-…
juliusmarminge Nov 14, 2026
1519fca
Merge remote-tracking branch 'origin/main' into t3code/windows-spawn-…
juliusmarminge Nov 14, 2026
d193c51
Merge remote-tracking branch 'origin/main' into t3code/windows-spawn-…
juliusmarminge Nov 14, 2026
a30e66f
Merge remote-tracking branch 'origin/main' into t3code/windows-spawn-…
juliusmarminge Nov 14, 2026
0a25c1b
Merge remote-tracking branch 'origin/main' into t3code/windows-spawn-…
juliusmarminge Nov 14, 2026
22cfd30
Merge remote-tracking branch 'origin/main' into t3code/windows-spawn-…
juliusmarminge Nov 14, 2026
235bbb4
Merge remote-tracking branch 'origin/main' into t3code/windows-spawn-…
juliusmarminge Nov 14, 2026
e0425c6
Merge remote-tracking branch 'origin/main' into t3code/windows-spawn-…
juliusmarminge Nov 14, 2026
12aecc0
Merge remote-tracking branch 'origin/main' into t3code/windows-spawn-…
juliusmarminge Nov 14, 2026
eb7be9e
Merge remote-tracking branch 'origin/main' into t3code/windows-spawn-…
juliusmarminge Nov 14, 2026
4c7a15e
Merge remote-tracking branch 'origin/main' into t3code/windows-spawn-…
juliusmarminge Nov 14, 2026
004b01b
Merge remote-tracking branch 'origin/main' into t3code/windows-spawn-…
juliusmarminge Nov 14, 2026
9522e4c
Merge remote-tracking branch 'origin/main' into t3code/windows-spawn-…
juliusmarminge Nov 14, 2026
1a3cecd
fix(server): Claude turns on Windows also launch on a launcher thread
juliusmarminge Nov 14, 2026
9bfde05
fix(server): the Claude capabilities probe on Windows launches on a l…
juliusmarminge Nov 14, 2026
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
4 changes: 2 additions & 2 deletions apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@ import {
} from "@t3tools/shared/mcpApp";
import { AgentScope } from "@t3tools/shared/AgentScope";
import * as ProviderProcesses from "../../process/providerProcesses.ts";
import * as SpawnWorker from "../../process/SpawnWorker.ts";
import * as HostProcess from "@t3tools/shared/HostProcess";
import { applyClaudePromptEffortPrefix } from "@t3tools/shared/model";
import { compareSemverVersions } from "@t3tools/shared/semver";
Expand Down Expand Up @@ -1524,12 +1525,11 @@ export const layerQueryRunner: Layer.Layer<
}
return child;
}
const child = NodeChildProcess.spawn(spawnOptions.command, spawnOptions.args, {
const child = SpawnWorker.spawnPiped(spawnOptions.command, spawnOptions.args, {
cwd: spawnOptions.cwd,
env: spawnOptions.env,
signal: spawnOptions.signal,
windowsHide: true,
stdio: ["pipe", "pipe", "pipe"],
});
child.stderr
.setEncoding("utf8")
Expand Down
163 changes: 163 additions & 0 deletions apps/server/src/process/SpawnWorker.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
// @effect-diagnostics nodeBuiltinImport:off processEnvInEffect:off - fixtures need real paths, the process resource list and the live environment.
import * as NodeOS from "node:os";
import * as NodePath from "node:path";
import * as NodeServices from "@effect/platform-node/NodeServices";
import { describe, expect, it } from "@effect/vitest";
import * as Deferred from "effect/Deferred";
import * as Effect from "effect/Effect";
import * as Fiber from "effect/Fiber";
import * as Layer from "effect/Layer";
import * as Result from "effect/Result";
import * as Stream from "effect/Stream";
import * as ChildProcess from "effect/process/ChildProcess";
import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner";
import * as HostProcess from "@t3tools/shared/HostProcess";

import * as SpawnWorker from "./SpawnWorker.ts";

const layer = SpawnWorker.layer.pipe(Layer.provide(NodeServices.layer));
const text = <E>(stream: Stream.Stream<Uint8Array, E>) =>
stream.pipe(Stream.decodeText(), Stream.mkString);
const node = (script: string, args: ReadonlyArray<string> = [], options = {}) =>
ChildProcess.make(process.execPath, ["-e", script, ...args], options);

describe("SpawnWorker", () => {
it.live("runs the server's Windows children on a launcher thread, not the server thread", () =>
Effect.gen(function* () {
for (const [platform, onServerThread] of [
["win32", false],
["linux", true],
] as const) {
const launched = Effect.gen(function* () {
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
const child = yield* spawner.spawn(
node("process.stdout.write('ready');process.stdin.resume()"),
);
expect(yield* child.stdout.pipe(Stream.take(1), text)).toBe("ready");
// The thread that called spawn owns the native process handle.
expect(process.getActiveResourcesInfo().includes("ProcessWrap")).toBe(onServerThread);
expect(yield* child.isRunning).toBe(true);
});
yield* launched.pipe(
Effect.scoped,
Effect.provide(SpawnWorker.layerServer.pipe(Layer.provide(NodeServices.layer))),
Effect.provideService(HostProcess.Platform, platform),
);
}
}),
);

it.live("preserves stdin, large output, env, cwd, literal args and additional descriptors", () =>
Effect.gen(function* () {
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
const literal = "quote' \" $HOME; * & %PATH%";
const cwd = NodeOS.tmpdir();
const script = `
const fs = require('node:fs');
const input = fs.readFileSync(0, 'utf8');
fs.writeSync(1, JSON.stringify([input, process.cwd(), process.env.SPAWN_WORKER_TEST, process.argv[1]]) + '\\n');
fs.writeSync(1, 'a'.repeat(1024 * 1024));
fs.writeSync(2, 'b'.repeat(1024 * 1024));
`;
for (let i = 0; i < 3; i++) {
const child = yield* spawner.spawn(
node(script, [literal], {
cwd,
env: { SPAWN_WORKER_TEST: "inherited value" },
extendEnv: true,
stdin: Stream.encodeText(Stream.make("stdin value")),
}),
);
const [stdout, stderr, code] = yield* Effect.all(
[text(child.stdout), text(child.stderr), child.exitCode],
{ concurrency: "unbounded" },
);
expect(stdout).toBe(
JSON.stringify(["stdin value", NodePath.resolve(cwd), "inherited value", literal]) +
"\n" +
"a".repeat(1024 * 1024),
);
expect(stderr).toBe("b".repeat(1024 * 1024));
expect(code).toBe(0);
}
// The launcher threads already exist, so this needs the server's environment at launch time.
process.env.SPAWN_WORKER_LATE = "set after start";
const late = yield* spawner.spawn(
node("process.stdout.write(process.env.SPAWN_WORKER_LATE)"),
);
expect(yield* text(late.stdout)).toBe("set after start");
delete process.env.SPAWN_WORKER_LATE;
const extra = yield* spawner.spawn(
node("require('node:fs').writeSync(3, 'fd3 value')", [], {
additionalFds: { fd3: { type: "output" } },
}),
);
const [fd3, extraCode] = yield* Effect.all([text(extra.getOutputFd(3)), extra.exitCode], {
concurrency: "unbounded",
});
expect(fd3).toBe("fd3 value");
expect(extraCode).toBe(0);
const reader = yield* spawner.spawn(
node("process.stdin.on('end', () => process.stdout.write('eof')).resume()"),
);
yield* Stream.fail("abandoned input").pipe(Stream.run(reader.stdin), Effect.ignore);
expect(yield* text(reader.stdout)).toBe("eof");
const pipeline = node("process.stdout.write('piped')").pipe(
ChildProcess.pipeTo(
node("process.stdin.on('data', (d) => process.stdout.write(String(d).toUpperCase()))"),
),
);
const piped = yield* spawner.spawn(pipeline);
expect(yield* text(piped.stdout)).toBe("PIPED");
expect(yield* piped.exitCode).toBe(0);
}).pipe(Effect.scoped, Effect.provide(layer)),
);

it.live("reports launch errors and exit codes, and keeps launching after either", () =>
Effect.gen(function* () {
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
const missing = yield* spawner
.spawn(ChildProcess.make("t3-missing-command-25245"))
.pipe(Effect.result);
expect(Result.isFailure(missing)).toBe(true);
if (Result.isFailure(missing)) expect(missing.failure.reason._tag).toBe("NotFound");
const failed = yield* spawner.spawn(node("process.exit(23)"));
expect(yield* failed.exitCode).toBe(23);
const healthy = yield* spawner.spawn(node("process.stdout.write('ok')"));
expect(yield* text(healthy.stdout)).toBe("ok");
expect(yield* healthy.exitCode).toBe(0);
}).pipe(Effect.scoped, Effect.provide(layer)),
);

it.live("kills running commands, detached or not, and on scope interruption", () =>
Effect.gen(function* () {
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
for (const detached of [true, false]) {
const child = yield* spawner.spawn(
node("process.stdout.write(String(process.pid));process.stdin.resume()", [], {
detached,
}),
);
const pid = yield* child.stdout.pipe(Stream.take(1), text);
expect(Number(pid)).toBe(child.pid);
yield* child.kill();
expect(yield* child.isRunning).toBe(false);
}
const started = yield* Deferred.make<ChildProcessSpawner.ChildProcessHandle>();
const running = yield* Effect.gen(function* () {
const scoped = yield* spawner.spawn(node("process.stdin.resume()"));
yield* Deferred.succeed(started, scoped);
return yield* Effect.never;
}).pipe(Effect.scoped, Effect.forkChild);
const scoped = yield* Deferred.await(started);
yield* Fiber.interrupt(running);
expect(yield* scoped.isRunning).toBe(false);
// Direct callers such as the Claude adapter pass an AbortSignal, which cannot cross to a worker.
const direct = SpawnWorker.spawnPiped(process.execPath, ["-e", "process.stdin.resume()"], {
signal: AbortSignal.abort(),
});
const exited = new Promise((resolve) => direct.once("exit", () => resolve(direct.killed)));
expect(yield* Effect.promise(() => exited)).toBe(true);
}).pipe(Effect.scoped, Effect.provide(layer)),
);
});
Loading
Loading