| 1 | import { PlatformServices } from "@/Util/PlatformServices.ts"; |
| 2 | import { assert, describe, expect, it } from "alchemy-test"; |
| 3 | import * as Duration from "effect/Duration"; |
| 4 | import * as Effect from "effect/Effect"; |
| 5 | import * as Schedule from "effect/Schedule"; |
| 6 | import * as Stream from "effect/Stream"; |
| 7 | import * as ChildProcess from "effect/process/ChildProcess"; |
| 8 | import { fileURLToPath } from "node:url"; |
| 9 | import { |
| 10 | assertPidExited, |
| 11 | killPid, |
| 12 | pidListeningOn, |
| 13 | waitForExit, |
| 14 | } from "./fixtures/process-effect.ts"; |
| 15 | |
| 16 | const FIXTURE_DIR = fileURLToPath( |
| 17 | new URL("./fixtures/dev-cli/", import.meta.url), |
| 18 | ); |
| 19 | const 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). */ |
| 24 | const 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. */ |
| 47 | const 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 | |
| 65 | describe.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 | ); |