1import type { ScopedPlanStatusSession } from "@/Report.ts";
2import * as Command from "@/Command";
3import * as Test from "@/Test/Alchemy";
4import { assert, expect } from "alchemy-test";
5import * as Effect from "effect/Effect";
6import * as FileSystem from "effect/FileSystem";
7import * as Redacted from "effect/Redacted";
8import * as Exit from "effect/Exit";
9import * as Fiber from "effect/Fiber";
10import * as Scope from "effect/Scope";
11import * as Clock from "effect/Clock";
12import * as Cause from "effect/Cause";
13import * as ChildProcess from "effect/process/ChildProcess";
14import {
15 assertDead,
16 lifecycleFixture,
17 pgid,
18 pidAlive,
19} from "./fixture/lifecycle-support.ts";
20import * as pathe from "pathe";
21const { test } = Test.make({ providers: Command.providers() });
22
23const session = (notes: Array<string>) =>
24 ({
25 note: (note: string) =>
26 Effect.sync(() => {
27 notes.push(note);
28 }),
29 }) as unknown as ScopedPlanStatusSession;
30
31const withExecutor = <A, E, R>(effect: Effect.Effect<A, E, R>) =>
32 effect.pipe(Effect.provide(Command.CommandExecutorLive()));
33
34test(
35 "returns stdout and stderr for a successful command",
36 withExecutor(
37 Effect.gen(function* () {
38 const executor = yield* Command.CommandExecutor;
39 const notes: Array<string> = [];
40 const result = yield* executor.run(
41 {
42 command: "printf 'hello'; printf 'warning' >&2",
43 shell: true,
44 },
45 session(notes),
46 );
47
48 expect(result).toEqual({
49 exitCode: 0,
50 stdout: "hello",
51 stderr: "warning",
52 });
53 expect(notes).toEqual(expect.arrayContaining(["hello", "warning"]));
54 }),
55 ),
56 { tags: ["unit", "local"], timeout: 30_000 },
57);
58
59test(
60 "returns a typed failure for a non-zero exit",
61 withExecutor(
62 Effect.gen(function* () {
63 const executor = yield* Command.CommandExecutor;
64 const error = yield* executor
65 .run(
66 {
67 command: "printf 'plain failure' >&2; exit 7",
68 shell: true,
69 },
70 session([]),
71 )
72 .pipe(Effect.flip);
73
74 assert(Command.isCommandError(error));
75 assert(error.reason._tag === "UnexpectedExit");
76 expect(error.reason.exitCode).toBe(7);
77 expect(error.reason.stderr).toBe("plain failure");
78 expect(error.message).toContain("exited with code 7");
79 }),
80 ),
81 { tags: ["unit", "local"], timeout: 30_000 },
82);
83
84test(
85 "times out and terminates descendants that ignore SIGTERM",
86 withExecutor(
87 Effect.gen(function* () {
88 const fs = yield* FileSystem.FileSystem;
89 const executor = yield* Command.CommandExecutor;
90 const cwd = yield* fs.makeTempDirectoryScoped();
91 const pidFile = pathe.join(cwd, "child.pid");
92
93 const invalid = yield* executor
94 .run({ command: "true", shell: true, timeout: 0 }, session([]))
95 .pipe(Effect.flip);
96 assert(Command.isCommandError(invalid));
97 assert(invalid.reason._tag === "BadArgument");
98 expect(invalid.message).toContain("positive finite duration");
99
100 const error = yield* executor
101 .run(
102 {
103 command: 'trap \'\' TERM; sleep 30 & echo "$!" > "$PID_FILE"; wait',
104 cwd,
105 env: { PID_FILE: pidFile },
106 shell: true,
107 timeout: "100 millis",
108 },
109 session([]),
110 )
111 .pipe(Effect.flip);
112
113 assert(Command.isCommandError(error));
114 assert(error.reason._tag === "CommandTimedOut");
115 expect(error.reason.timeout).toBe("100ms");
116 expect(error.message).toContain("process group was terminated");
117
118 const childPid = Number((yield* fs.readFileString(pidFile)).trim());
119 expect(Number.isSafeInteger(childPid)).toBe(true);
120
121 let alive = true;
122 for (let attempt = 0; attempt < 20; attempt++) {
123 alive = yield* Effect.sync(() => {
124 try {
125 process.kill(childPid, 0);
126 return true;
127 } catch (cause) {
128 if (
129 typeof cause === "object" &&
130 cause !== null &&
131 "code" in cause &&
132 cause.code === "ESRCH"
133 ) {
134 return false;
135 }
136 throw cause;
137 }
138 });
139 if (!alive) break;
140 yield* Effect.sleep("50 millis");
141 }
142 expect(alive).toBe(false);
143 }),
144 ),
145 { tags: ["unit", "local"], timeout: 30_000 },
146);
147
148test(
149 "redacts Redacted env values across chunks, notes, results, and errors",
150 withExecutor(
151 Effect.gen(function* () {
152 const executor = yield* Command.CommandExecutor;
153 const secret = "super-secret-value";
154 const notes: Array<string> = [];
155 const splitSecret =
156 "printf 'super-secret-'; sleep 0.05; printf 'value\\n'; " +
157 "printf 'super-secret-' >&2; sleep 0.05; printf 'value\\n' >&2";
158 const props = {
159 command: splitSecret,
160 shell: true,
161 env: { SECRET: Redacted.make(secret) },
162 } as const;
163
164 const result = yield* executor.run(props, session(notes));
165 expect(result.stdout).toBe("[REDACTED]\n");
166 expect(result.stderr).toBe("[REDACTED]\n");
167 expect(notes.join("\n")).not.toContain(secret);
168 expect(notes.join("\n")).toContain("[REDACTED]");
169
170 const error = yield* executor
171 .run(
172 {
173 command: "printf '%s\\n' \"$SECRET\" >&2; exit 9",
174 shell: true,
175 env: { SECRET: Redacted.make(secret) },
176 },
177 session(notes),
178 )
179 .pipe(Effect.flip);
180
181 assert(Command.isCommandError(error));
182 assert(error.reason._tag === "UnexpectedExit");
183 expect(error.reason.stderr).toBe("[REDACTED]\n");
184 expect(error.message).not.toContain(secret);
185 expect(JSON.stringify(error)).not.toContain(secret);
186 expect(notes.join("\n")).not.toContain(secret);
187
188 const spawnError = yield* executor
189 .run(
190 {
191 command: secret,
192 env: { SECRET: Redacted.make(secret) },
193 },
194 session(notes),
195 )
196 .pipe(Effect.flip);
197 assert(Command.isCommandError(spawnError));
198 expect(spawnError.message).not.toContain(secret);
199 expect(JSON.stringify(spawnError)).not.toContain(secret);
200
201 // Even the conventional marker itself can be a legitimate secret. The
202 // replacement must never reproduce that exact value.
203 const markerCollision = "[REDACTED]";
204 const collisionNotes: Array<string> = [];
205 const collision = yield* executor.run(
206 {
207 command: "printf '%s' \"$SECRET\"",
208 shell: true,
209 env: { SECRET: Redacted.make(markerCollision) },
210 },
211 session(collisionNotes),
212 );
213 expect(collision.stdout).not.toContain(markerCollision);
214 expect(collisionNotes.join("\n")).not.toContain(markerCollision);
215 }),
216 ),
217 { tags: ["unit", "local"], timeout: 30_000 },
218);
219
220for (const mode of ["cooperative", "stubborn", "early-exit"]) {
221 test.skipIf(process.platform === "win32")(
222 `scoped cleanup uses TERM before bounded escalation (${mode})`,
223 withExecutor(
224 Effect.gen(function* () {
225 const fixture = yield* lifecycleFixture(mode);
226 const executor = yield* Command.CommandExecutor;
227 const scope = yield* Effect.acquireRelease(Scope.make(), (scope) =>
228 Scope.close(scope, Exit.void),
229 );
230 yield* executor.spawn(fixture.props).pipe(Scope.provide(scope));
231 const pids = yield* fixture.ready;
232 expect(yield* pgid(pids.leaf)).toBe(
233 mode === "cooperative" ? pids.leaf : pids.wrapper,
234 );
235 const started = yield* Clock.currentTimeMillis;
236 yield* Scope.close(scope, Exit.void);
237 expect((yield* Clock.currentTimeMillis) - started).toBeLessThan(3500);
238 expect(yield* fixture.has("wrapper.term")).toBe(true);
239 expect(yield* fixture.has("wrapper.clean")).toBe(
240 mode === "cooperative",
241 );
242 yield* assertDead(pids.wrapper);
243 yield* assertDead(pids.leaf);
244 }),
245 ),
246 { tags: ["unit", "local"], timeout: 20_000 },
247 );
248}
249
250test.skipIf(process.platform === "win32")(
251 "a nonzero leader exit hard-cleans its TERM-ignoring descendant before scope closure",
252 withExecutor(
253 Effect.gen(function* () {
254 const fixture = yield* lifecycleFixture("crash");
255 const executor = yield* Command.CommandExecutor;
256 const fiber = yield* executor
257 .run(fixture.props, session([]))
258 .pipe(Effect.flip, Effect.forkScoped);
259 const pids = yield* fixture.ready;
260 expect(yield* pgid(pids.leaf)).toBe(pids.wrapper);
261 yield* fixture.crash;
262 const error = yield* Fiber.join(fiber).pipe(Effect.timeout("3 seconds"));
263 expect(error.reason._tag).toBe("UnexpectedExit");
264 yield* assertDead(pids.wrapper);
265 yield* assertDead(pids.leaf);
266 }),
267 ),
268 { tags: ["unit", "local"], timeout: 20_000 },
269);
270
271test.skipIf(process.platform === "win32")(
272 "negative control: immediate KILL prevents wrapper cleanup and leaves its detached child",
273 Effect.gen(function* () {
274 const fixture = yield* lifecycleFixture();
275 const scope = yield* Effect.acquireRelease(Scope.make(), (scope) =>
276 Scope.close(scope, Exit.void),
277 );
278 yield* ChildProcess.make("bun", ["run", fixture.entry], {
279 env: fixture.props.env,
280 extendEnv: true,
281 killSignal: "SIGKILL",
282 }).pipe(Scope.provide(scope));
283 const pids = yield* fixture.ready;
284 yield* Scope.close(scope, Exit.void);
285 expect(yield* fixture.has("wrapper.clean")).toBe(false);
286 expect(yield* pidAlive(pids.leaf)).toBe(true);
287 }),
288 { tags: ["unit", "local"], timeout: 20_000 },
289);
290
291test.skipIf(process.platform === "win32")(
292 "cancelling run without a timeout gracefully cleans a wrapper and detached child",
293 withExecutor(
294 Effect.gen(function* () {
295 const fixture = yield* lifecycleFixture();
296 const executor = yield* Command.CommandExecutor;
297 const fiber = yield* executor
298 .run(fixture.props, session([]))
299 .pipe(Effect.forkScoped);
300 const pids = yield* fixture.ready;
301 yield* Fiber.interrupt(fiber);
302 const exit = yield* Fiber.await(fiber);
303 assert(Exit.isFailure(exit));
304 expect(Cause.hasInterruptsOnly(exit.cause)).toBe(true);
305 expect(yield* fixture.has("wrapper.clean")).toBe(true);
306 yield* assertDead(pids.wrapper);
307 yield* assertDead(pids.leaf);
308 }),
309 ),
310 { tags: ["unit", "local"], timeout: 20_000 },
311);
312
313test.skipIf(process.platform === "win32")(
314 "finite timeout preserves its typed error after graceful wrapper cleanup",
315 withExecutor(
316 Effect.gen(function* () {
317 const fixture = yield* lifecycleFixture();
318 const executor = yield* Command.CommandExecutor;
319 const fiber = yield* executor
320 .run({ ...fixture.props, timeout: "2 seconds" }, session([]))
321 .pipe(Effect.flip, Effect.forkScoped);
322 const pids = yield* fixture.ready;
323 const error = yield* Fiber.join(fiber);
324 expect(error.reason._tag).toBe("CommandTimedOut");
325 expect(yield* fixture.has("wrapper.clean")).toBe(true);
326 yield* assertDead(pids.wrapper);
327 yield* assertDead(pids.leaf);
328 }),
329 ),
330 { tags: ["unit", "local"], timeout: 20_000 },
331);
332
333test.skipIf(process.platform === "win32")(
334 "negative control: TERM without escalation leaves stubborn processes alive",
335 Effect.gen(function* () {
336 const fixture = yield* lifecycleFixture("stubborn");
337 const child = yield* ChildProcess.make("bun", ["run", fixture.entry], {
338 env: fixture.props.env,
339 extendEnv: true,
340 forceKillAfter: "1 second",
341 });
342 const pids = yield* fixture.ready;
343 yield* child
344 .kill({ killSignal: "SIGTERM" })
345 .pipe(Effect.timeoutOption("200 millis"));
346 expect(yield* fixture.has("wrapper.term")).toBe(true);
347 expect(yield* pidAlive(pids.wrapper)).toBe(true);
348 expect(yield* pidAlive(pids.leaf)).toBe(true);
349 }),
350 { tags: ["unit", "local"], timeout: 20_000 },
351);