| author | |
| committer | |
| log | 2ae63f4aa2a2af7b0ed605582aef7c9025297e13 |
| tree | 11c96812181337393372866ad02bdf5b82d2b363 |
| parent | 3d0233e3b0071ba3cab0d0a8a3585d955979e234 |
Add tree-kill via taskkill /T /F in processRunner and codexAppServerManager so timeout enforcement and session stop properly terminate the entire process tree instead of just the cmd.exe wrapper. Detect Windows command not found (exit 9009) in normalizeExitError. Pre-validate editor commands with isCommandAvailable in open.ts before spawning. Fix ProviderHealth test import and dev-runner test path assertion for Windows.
Made-with: Cursor5 files changed, 82 insertions(+), 28 deletions(-)
apps/server/src/codexAppServerManager.ts+19-2| ... | ... | @@ -1,4 +1,4 @@ |
| 1 | import { type ChildProcessWithoutNullStreams, spawn } from "node:child_process"; | |
| 1 | import { type ChildProcessWithoutNullStreams, spawn, spawnSync } from "node:child_process"; | |
| 2 | 2 | import { randomUUID } from "node:crypto"; |
| 3 | 3 | import { EventEmitter } from "node:events"; |
| 4 | 4 | import readline from "node:readline"; |
| ... | ... | @@ -104,6 +104,23 @@ const RECOVERABLE_THREAD_RESUME_ERROR_SNIPPETS = [ |
| 104 | 104 | "does not exist", |
| 105 | 105 | ]; |
| 106 | 106 | |
| 107 | /** | |
| 108 | * On Windows with `shell: true`, `child.kill()` only terminates the `cmd.exe` | |
| 109 | * wrapper, leaving the actual command running. Use `taskkill /T` to kill the | |
| 110 | * entire process tree instead. | |
| 111 | */ | |
| 112 | function killChildTree(child: ChildProcessWithoutNullStreams): void { | |
| 113 | if (process.platform === "win32" && child.pid !== undefined) { | |
| 114 | try { | |
| 115 | spawnSync("taskkill", ["/pid", String(child.pid), "/T", "/F"], { stdio: "ignore" }); | |
| 116 | return; | |
| 117 | } catch { | |
| 118 | // fallback to direct kill | |
| 119 | } | |
| 120 | } | |
| 121 | child.kill(); | |
| 122 | } | |
| 123 | ||
| 107 | 124 | export function normalizeCodexModelSlug( |
| 108 | 125 | model: string | undefined | null, |
| 109 | 126 | preferredId?: string, |
| ... | ... | @@ -476,7 +493,7 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve |
| 476 | 493 | context.output.close(); |
| 477 | 494 | |
| 478 | 495 | if (!context.child.killed) { |
| 479 | context.child.kill(); | |
| 496 | killChildTree(context.child); | |
| 480 | 497 | } |
| 481 | 498 | |
| 482 | 499 | this.updateSession(context, { |
apps/server/src/open.ts+28-20| ... | ... | @@ -224,29 +224,37 @@ export const resolveEditorLaunch = Effect.fnUntraced(function* ( |
| 224 | 224 | }); |
| 225 | 225 | |
| 226 | 226 | export const launchDetached = (launch: EditorLaunch) => |
| 227 | Effect.callback<void, OpenError>((resume) => { | |
| 228 | let child; | |
| 229 | try { | |
| 230 | child = spawn(launch.command, [...launch.args], { | |
| 231 | detached: true, | |
| 232 | stdio: "ignore", | |
| 233 | shell: process.platform === "win32", | |
| 234 | }); | |
| 235 | } catch (error) { | |
| 236 | return resume( | |
| 237 | Effect.fail(new OpenError({ message: "failed to spawn detached process", cause: error })), | |
| 238 | ); | |
| 227 | Effect.gen(function* () { | |
| 228 | if (!isCommandAvailable(launch.command)) { | |
| 229 | return yield* new OpenError({ message: `Editor command not found: ${launch.command}` }); | |
| 239 | 230 | } |
| 240 | 231 | |
| 241 | const handleSpawn = () => { | |
| 242 | child.unref(); | |
| 243 | resume(Effect.void); | |
| 244 | }; | |
| 232 | yield* Effect.callback<void, OpenError>((resume) => { | |
| 233 | let child; | |
| 234 | try { | |
| 235 | child = spawn(launch.command, [...launch.args], { | |
| 236 | detached: true, | |
| 237 | stdio: "ignore", | |
| 238 | shell: process.platform === "win32", | |
| 239 | }); | |
| 240 | } catch (error) { | |
| 241 | return resume( | |
| 242 | Effect.fail( | |
| 243 | new OpenError({ message: "failed to spawn detached process", cause: error }), | |
| 244 | ), | |
| 245 | ); | |
| 246 | } | |
| 245 | 247 | |
| 246 | child.once("spawn", handleSpawn); | |
| 247 | child.once("error", (cause) => | |
| 248 | resume(Effect.fail(new OpenError({ message: "failed to spawn detached process", cause }))), | |
| 249 | ); | |
| 248 | const handleSpawn = () => { | |
| 249 | child.unref(); | |
| 250 | resume(Effect.void); | |
| 251 | }; | |
| 252 | ||
| 253 | child.once("spawn", handleSpawn); | |
| 254 | child.once("error", (cause) => | |
| 255 | resume(Effect.fail(new OpenError({ message: "failed to spawn detached process", cause }))), | |
| 256 | ); | |
| 257 | }); | |
| 250 | 258 | }); |
| 251 | 259 | |
| 252 | 260 | const make = Effect.gen(function* () { |
apps/server/src/processRunner.ts+31-4| ... | ... | @@ -1,4 +1,4 @@ |
| 1 | import { spawn } from "node:child_process"; | |
| 1 | import { type ChildProcess as ChildProcessHandle, spawn, spawnSync } from "node:child_process"; | |
| 2 | 2 | |
| 3 | 3 | export interface ProcessRunOptions { |
| 4 | 4 | cwd?: string | undefined; |
| ... | ... | @@ -37,11 +37,21 @@ function normalizeSpawnError(command: string, args: readonly string[], error: un |
| 37 | 37 | return new Error(`Failed to run ${commandLabel(command, args)}: ${error.message}`); |
| 38 | 38 | } |
| 39 | 39 | |
| 40 | function isWindowsCommandNotFound(code: number | null, stderr: string): boolean { | |
| 41 | if (process.platform !== "win32") return false; | |
| 42 | if (code === 9009) return true; | |
| 43 | return /is not recognized as an internal or external command/i.test(stderr); | |
| 44 | } | |
| 45 | ||
| 40 | 46 | function normalizeExitError( |
| 41 | 47 | command: string, |
| 42 | 48 | args: readonly string[], |
| 43 | 49 | result: ProcessRunResult, |
| 44 | 50 | ): Error { |
| 51 | if (isWindowsCommandNotFound(result.code, result.stderr)) { | |
| 52 | return new Error(`Command not found: ${command}`); | |
| 53 | } | |
| 54 | ||
| 45 | 55 | const reason = result.timedOut |
| 46 | 56 | ? "timed out" |
| 47 | 57 | : `failed (code=${result.code ?? "null"}, signal=${result.signal ?? "null"})`; |
| ... | ... | @@ -70,6 +80,23 @@ function normalizeBufferError( |
| 70 | 80 | |
| 71 | 81 | const DEFAULT_MAX_BUFFER_BYTES = 8 * 1024 * 1024; |
| 72 | 82 | |
| 83 | /** | |
| 84 | * On Windows with `shell: true`, `child.kill()` only terminates the `cmd.exe` | |
| 85 | * wrapper, leaving the actual command running. Use `taskkill /T` to kill the | |
| 86 | * entire process tree instead. | |
| 87 | */ | |
| 88 | function killChild(child: ChildProcessHandle, signal: NodeJS.Signals = "SIGTERM"): void { | |
| 89 | if (process.platform === "win32" && child.pid !== undefined) { | |
| 90 | try { | |
| 91 | spawnSync("taskkill", ["/pid", String(child.pid), "/T", "/F"], { stdio: "ignore" }); | |
| 92 | return; | |
| 93 | } catch { | |
| 94 | // fallback to direct kill | |
| 95 | } | |
| 96 | } | |
| 97 | child.kill(signal); | |
| 98 | } | |
| 99 | ||
| 73 | 100 | function appendChunkWithinLimit( |
| 74 | 101 | target: string, |
| 75 | 102 | currentBytes: number, |
| ... | ... | @@ -127,9 +154,9 @@ export async function runProcess( |
| 127 | 154 | |
| 128 | 155 | const timeoutTimer = setTimeout(() => { |
| 129 | 156 | timedOut = true; |
| 130 | child.kill("SIGTERM"); | |
| 157 | killChild(child, "SIGTERM"); | |
| 131 | 158 | forceKillTimer = setTimeout(() => { |
| 132 | child.kill("SIGKILL"); | |
| 159 | killChild(child, "SIGKILL"); | |
| 133 | 160 | }, 1_000); |
| 134 | 161 | }, timeoutMs); |
| 135 | 162 | |
| ... | ... | @@ -144,7 +171,7 @@ export async function runProcess( |
| 144 | 171 | }; |
| 145 | 172 | |
| 146 | 173 | const fail = (error: Error): void => { |
| 147 | child.kill("SIGTERM"); | |
| 174 | killChild(child, "SIGTERM"); | |
| 148 | 175 | finalize(() => { |
| 149 | 176 | reject(error); |
| 150 | 177 | }); |
apps/server/src/provider/Layers/ProviderHealth.test.ts+2-1| ... | ... | @@ -1,4 +1,5 @@ |
| 1 | import { assert, it } from "@effect/vitest"; | |
| 1 | import assert from "node:assert/strict"; | |
| 2 | import { it } from "@effect/vitest"; | |
| 2 | 3 | import { Effect, Layer, Sink, Stream } from "effect"; |
| 3 | 4 | import * as PlatformError from "effect/PlatformError"; |
| 4 | 5 | import { ChildProcessSpawner } from "effect/unstable/process"; |
scripts/dev-runner.test.ts+2-1| ... | ... | @@ -1,4 +1,5 @@ |
| 1 | 1 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 2 | import { resolve } from "node:path"; | |
| 2 | 3 | import { assert, describe, it } from "@effect/vitest"; |
| 3 | 4 | import { Effect } from "effect"; |
| 4 | 5 | |
| ... | ... | @@ -86,7 +87,7 @@ it.layer(NodeServices.layer)("dev-runner", (it) => { |
| 86 | 87 | devUrl: new URL("http://localhost:7331"), |
| 87 | 88 | }); |
| 88 | 89 | |
| 89 | assert.equal(env.T3CODE_STATE_DIR, "/tmp/override-state"); | |
| 90 | assert.equal(env.T3CODE_STATE_DIR, resolve("/tmp/override-state")); | |
| 90 | 91 | assert.equal(env.T3CODE_PORT, "4222"); |
| 91 | 92 | assert.equal(env.VITE_WS_URL, "ws://localhost:4222"); |
| 92 | 93 | assert.equal(env.T3CODE_NO_BROWSER, "1"); |