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 FileSystem from "effect/FileSystem";
6import * as Path from "effect/Path";
7import * as Schedule from "effect/Schedule";
8import * as Clock from "effect/Clock";
9import {
10 assertDead,
11 lifecycleFixture,
12} from "../Command/fixture/lifecycle-support.ts";
13import * as Sink from "effect/Sink";
14import * as Stream from "effect/Stream";
15import * as ChildProcess from "effect/process/ChildProcess";
16import { fileURLToPath } from "node:url";
17import {
18 assertPidExited,
19 isAlive,
20 killPid,
21 pidListeningOn,
22 waitForExit,
23} from "./fixtures/process-effect.ts";
24import { runtimes } from "./fixtures/runtimes.ts";
25
26const PARENT_TS = fileURLToPath(
27 new URL("./fixtures/rpc-spawner-parent.ts", import.meta.url),
28);
29const CHILD_TS_URL = new URL(
30 "./fixtures/rpc-server-entry.ts",
31 import.meta.url,
32).toString();
33const DEVSERVER_PARENT_TS = fileURLToPath(
34 new URL("./fixtures/rpc-spawner-devserver-parent.ts", import.meta.url),
35);
36// The dev sidecar entry plus the provider group it serves `Command.Dev` from.
37const SIDECAR_TS_URL = new URL(
38 "../../src/Local/Sidecar.ts",
39 import.meta.url,
40).toString();
41const COMMAND_PROVIDERS_TS_URL = new URL(
42 "../../src/Command/Local.ts",
43 import.meta.url,
44).toString();
45const LONG_RUNNING_CJS = fileURLToPath(
46 new URL("../Command/fixture/long-running.cjs", import.meta.url),
47);
48
49for (const runtime of runtimes()) {
50 describe.skipIf(!runtime.available)(
51 `Local.RpcSpawner cleanup (${runtime.name})`,
52 { tags: ["local"] },
53 () => {
54 /**
55 * Boots the parent fixture and waits until it has reported both its own
56 * pid and the child's RPC url (from which we resolve the child's pid via
57 * `lsof`). Retries the stdout parse on a schedule until both fields are
58 * populated.
59 */
60 const launch = Effect.gen(function* () {
61 const [bin, ...args] = runtime.argv(PARENT_TS);
62 const child = yield* ChildProcess.make(bin, [...args, CHILD_TS_URL], {
63 stdout: "pipe",
64 forceKillAfter: "1 second",
65 });
66 const output = yield* child.stdout.pipe(
67 Stream.decodeText,
68 Stream.run(
69 Sink.fold(
70 () => "",
71 (acc) =>
72 !acc.includes("CHILD_URL=") || !acc.includes("PARENT_PID="),
73 (acc, chunk) => Effect.succeed(acc + chunk),
74 ),
75 ),
76 // The parent fixture live-transforms TypeScript and spawns a second
77 // process doing the same; under worker contention (and on Windows,
78 // where process spawn is slower) this can take well over 5 seconds.
79 Effect.timeout("30 seconds"),
80 );
81
82 const childUrl = output.match(/CHILD_URL=(\S+)/)?.[1];
83 const parentPid = Number.parseInt(
84 output.match(/PARENT_PID=(\d+)/)?.[1]!,
85 10,
86 );
87
88 assert(childUrl, `child url not found in output: ${output}`);
89 assert(
90 !Number.isNaN(parentPid),
91 `parent pid not found in output: ${output}`,
92 );
93
94 const childPid = yield* pidListeningOn(childUrl);
95
96 yield* Effect.addFinalizer(() => killPid(childPid, "SIGKILL"));
97
98 return {
99 child,
100 parentPid,
101 childPid,
102 };
103 });
104
105 it.live(
106 "child dies after parent receives SIGTERM",
107 () =>
108 Effect.gen(function* () {
109 const { child, parentPid, childPid } = yield* launch;
110 expect(yield* isAlive(childPid)).toBe(true);
111 yield* killPid(parentPid, "SIGTERM");
112 // waitForExit wraps `handle.exitCode`, which resolves once
113 // the OS reports the parent's exit.
114 yield* waitForExit(child, Duration.seconds(10));
115 yield* assertPidExited(childPid);
116 }).pipe(Effect.provide(PlatformServices)),
117 { tags: ["local"], timeout: 45_000 },
118 );
119
120 it.live(
121 "child dies after parent receives SIGKILL",
122 () =>
123 Effect.gen(function* () {
124 const { child, parentPid, childPid } = yield* launch;
125 expect(yield* isAlive(childPid)).toBe(true);
126 yield* killPid(parentPid, "SIGKILL");
127 yield* waitForExit(child, Duration.seconds(10));
128 yield* assertPidExited(childPid);
129 }).pipe(Effect.provide(PlatformServices)),
130 { tags: ["local"], timeout: 45_000 },
131 );
132
133 it.live(
134 "DevServer child dies after parent receives SIGTERM",
135 () =>
136 Effect.gen(function* () {
137 const [bin, ...args] = runtime.argv(DEVSERVER_PARENT_TS);
138 const fs = yield* FileSystem.FileSystem;
139 // `/tmp` doesn't exist on Windows — use a real temp directory.
140 const tmpDir = yield* fs.makeTempDirectoryScoped({
141 prefix: "alchemy-devserver-",
142 });
143 const pidFile = `${tmpDir}/${process.pid}-${runtime.name}.json`;
144 const child = yield* ChildProcess.make(
145 bin,
146 [
147 ...args,
148 SIDECAR_TS_URL,
149 COMMAND_PROVIDERS_TS_URL,
150 `node ${LONG_RUNNING_CJS}`,
151 pidFile,
152 ],
153 {
154 stdout: "pipe",
155 forceKillAfter: "1 second",
156 },
157 );
158 const output = yield* child.stdout.pipe(
159 Stream.decodeText,
160 Stream.run(
161 Sink.fold(
162 () => "",
163 (acc) =>
164 !acc.includes("PARENT_PID=") ||
165 !acc.includes("DEVSERVER_PID="),
166 (acc, chunk) => Effect.succeed(acc + chunk),
167 ),
168 ),
169 // See `launch` above — fixture startup can exceed 10s under
170 // contention and on Windows.
171 Effect.timeout("30 seconds"),
172 );
173
174 const parentPid = Number.parseInt(
175 output.match(/PARENT_PID=(\d+)/)?.[1]!,
176 10,
177 );
178 const devServerPid = Number.parseInt(
179 output.match(/DEVSERVER_PID=(\d+)/)?.[1]!,
180 10,
181 );
182
183 assert(
184 !Number.isNaN(parentPid),
185 `parent pid not found in output: ${output}`,
186 );
187 assert(
188 !Number.isNaN(devServerPid),
189 `dev server pid not found in output: ${output}`,
190 );
191
192 yield* Effect.addFinalizer(() => killPid(devServerPid, "SIGKILL"));
193
194 expect(yield* isAlive(devServerPid)).toBe(true);
195 yield* killPid(parentPid, "SIGTERM");
196 yield* waitForExit(child, Duration.seconds(10));
197 yield* assertPidExited(devServerPid);
198 }).pipe(Effect.provide(PlatformServices)),
199 { tags: ["local"], timeout: 45_000 },
200 );
201 },
202 );
203}
204
205it.live.skipIf(process.platform === "win32")(
206 "sidecar shutdown finishes multiple Command grace periods before its outer hard kill",
207 () =>
208 Effect.gen(function* () {
209 const fs = yield* FileSystem.FileSystem;
210 const path = yield* Path.Path;
211 const home = yield* fs.makeTempDirectoryScoped({
212 directory: "/tmp",
213 prefix: "rpc-command-shutdown-",
214 });
215 const fixtures = yield* Effect.forEach(
216 ["stubborn", "stubborn", "stubborn", "cooperative"],
217 lifecycleFixture,
218 );
219 const ready = path.join(home, "ready");
220 const entry = yield* path.fromFileUrl(
221 new URL("./fixtures/rpc-spawner-commands.ts", import.meta.url),
222 );
223 const parent = yield* ChildProcess.make("bun", ["run", entry], {
224 env: {
225 ALCHEMY_HOME: home,
226 COMMAND_FIXTURES: JSON.stringify({
227 home,
228 ready,
229 commands: fixtures.map((fixture) => fixture.props),
230 }),
231 },
232 extendEnv: true,
233 stdout: "ignore",
234 stderr: "inherit",
235 forceKillAfter: "6 seconds",
236 });
237 expect(
238 yield* fs.exists(ready).pipe(
239 Effect.repeat({
240 schedule: Schedule.spaced("50 millis"),
241 until: Boolean,
242 times: 400,
243 }),
244 ),
245 ).toBe(true);
246 const pids = yield* Effect.forEach(fixtures, (fixture) => fixture.ready);
247 const start = yield* Clock.currentTimeMillis;
248 yield* parent.kill({
249 killSignal: "SIGTERM",
250 forceKillAfter: "6 seconds",
251 });
252 expect((yield* Clock.currentTimeMillis) - start).toBeLessThan(5000);
253 expect(yield* fixtures[3]!.has("wrapper.clean")).toBe(true);
254 for (const fixture of fixtures)
255 expect(yield* fixture.has("wrapper.term")).toBe(true);
256 for (const pair of pids) {
257 yield* assertDead(pair.wrapper);
258 yield* assertDead(pair.leaf);
259 }
260 }).pipe(Effect.scoped, Effect.provide(PlatformServices)),
261 { tags: ["local"], timeout: 40_000 },
262);