1import { PlatformServices } from "@/Util/PlatformServices.ts";
2import { assert, describe, expect, it } from "alchemy-test";
3import * as Duration from "effect/Duration";
4import * as Effect from "effect/Effect";
5import * as Schedule from "effect/Schedule";
6import * as Stream from "effect/Stream";
7import * as ChildProcess from "effect/process/ChildProcess";
8import { fileURLToPath } from "node:url";
9import {
10 assertPidExited,
11 killPid,
12 pidListeningOn,
13 waitForExit,
14} from "./fixtures/process-effect.ts";
15
16const FIXTURE_DIR = fileURLToPath(
17 new URL("./fixtures/dev-cli/", import.meta.url),
18);
19const ALCHEMY_BIN = fileURLToPath(
20 new URL("../../bin/alchemy.js", import.meta.url),
21);
22
23/** Every live pid on the system with its parent and command (POSIX). */
24const processTable = ChildProcess.make("ps", ["-Ao", "pid=,ppid=,comm="], {
25 stdout: "pipe",
26}).pipe(
27 Effect.flatMap((handle) =>
28 handle.stdout.pipe(Stream.decodeText, Stream.mkString),
29 ),
30 Effect.map((stdout) =>
31 stdout.split("\n").flatMap((line) => {
32 const match = line.trim().match(/^(\d+)\s+(\d+)\s+(.*)$/);
33 return match
34 ? [
35 {
36 pid: Number(match[1]),
37 ppid: Number(match[2]),
38 command: match[3]!,
39 },
40 ]
41 : [];
42 }),
43 ),
44);
45
46/** Transitive children of `root`, resolved to a fixpoint over the table. */
47const descendantsOf = (root: number) =>
48 processTable.pipe(
49 Effect.map((table) => {
50 const pids = new Set([root]);
51 let grew = true;
52 while (grew) {
53 grew = false;
54 for (const entry of table) {
55 if (pids.has(entry.ppid) && !pids.has(entry.pid)) {
56 pids.add(entry.pid);
57 grew = true;
58 }
59 }
60 }
61 return table.filter((entry) => entry.pid !== root && pids.has(entry.pid));
62 }),
63 );
64
65describe.skipIf(process.platform === "win32" || process.env.FAST)(
66 "alchemy dev CLI process cleanup",
67 { tags: ["local"] },
68 () => {
69 // Expected failure: SIGKILL cannot be trapped, so nothing kills the
70 // exec child, the provider sidecar, or workerd on the CLI's behalf.
71 // When the orphan story lands this starts passing — flip it to a
72 // regular test then.
73 it.live.fails(
74 "every spawned process exits after the CLI is SIGKILLed",
75 () =>
76 Effect.gen(function* () {
77 let output = "";
78 const child = yield* ChildProcess.make(
79 "bun",
80 [ALCHEMY_BIN, "dev", "alchemy.run.ts", "--stage", "kill-test"],
81 {
82 cwd: FIXTURE_DIR,
83 stdout: "pipe",
84 stderr: "pipe",
85 forceKillAfter: "1 second",
86 // The stack is fully local (localState + local worker), so
87 // placeholder credentials satisfy the provider's env lookup
88 // without any cloud call being possible.
89 env: {
90 CI: "1",
91 CLOUDFLARE_ACCOUNT_ID: "0123456789abcdef0123456789abcdef",
92 CLOUDFLARE_API_TOKEN: "dev-cli-kill-test-placeholder",
93 },
94 extendEnv: true,
95 },
96 );
97 const collect = (stream: typeof child.stdout) =>
98 stream.pipe(
99 Stream.decodeText,
100 Stream.runForEach((chunk) =>
101 Effect.sync(() => {
102 output += chunk;
103 }),
104 ),
105 Effect.ignore,
106 Effect.forkScoped,
107 );
108 yield* collect(child.stdout);
109 yield* collect(child.stderr);
110
111 // The stack outputs print once the dev deploy has converged and
112 // workerd is serving the local worker.
113 const match = yield* Effect.sync(
114 () => output.match(/workerUrl[^h]*(http[^\s'",]+)/)?.[1],
115 ).pipe(
116 Effect.repeat({
117 schedule: Schedule.spaced("500 millis"),
118 until: (value) => value !== undefined,
119 times: 240,
120 }),
121 );
122 if (typeof match !== "string") {
123 return yield* Effect.die(
124 new Error(
125 `alchemy dev never printed the worker url.\n--- output tail ---\n${output.slice(-4_000)}`,
126 ),
127 );
128 }
129 const url = match;
130 yield* Effect.tryPromise(() => fetch(url)).pipe(
131 Effect.retry({
132 schedule: Schedule.spaced("500 millis"),
133 times: 40,
134 }),
135 );
136
137 const workerdPid = yield* pidListeningOn(url);
138 const tracked = yield* descendantsOf(child.pid);
139 assert(
140 tracked.some((entry) => entry.pid === workerdPid),
141 `workerd (pid ${workerdPid}) is not a descendant of the CLI:\n${JSON.stringify(tracked, null, 2)}`,
142 );
143 // Reap survivors unconditionally so the expected failure never
144 // leaks processes into the rest of the suite.
145 yield* Effect.addFinalizer(() =>
146 Effect.forEach(tracked, (entry) =>
147 killPid(entry.pid, "SIGKILL"),
148 ).pipe(Effect.asVoid),
149 );
150
151 yield* killPid(child.pid, "SIGKILL");
152 yield* waitForExit(child, Duration.seconds(10));
153
154 const survivors: string[] = [];
155 yield* Effect.forEach(tracked, (entry) =>
156 assertPidExited(entry.pid).pipe(
157 Effect.catch(() =>
158 Effect.sync(() => {
159 survivors.push(`${entry.pid} ${entry.command}`);
160 }),
161 ),
162 ),
163 );
164 expect(survivors).toEqual([]);
165 }).pipe(Effect.provide(PlatformServices)),
166 { timeout: 180_000 },
167 );
168 },
169);