1import * as Effect from "effect/Effect";
2import * as FileSystem from "effect/FileSystem";
3import * as Path from "effect/Path";
4import * as Schedule from "effect/Schedule";
5import * as Stream from "effect/Stream";
6import * as ChildProcess from "effect/process/ChildProcess";
7
8export const pidAlive = (pid: number) =>
9 Effect.sync(() => {
10 try {
11 process.kill(pid, 0);
12 return true;
13 } catch (error) {
14 if (
15 typeof error === "object" &&
16 error !== null &&
17 "code" in error &&
18 error.code === "ESRCH"
19 )
20 return false;
21 throw error;
22 }
23 });
24
25export const assertDead = (pid: number) =>
26 pidAlive(pid).pipe(
27 Effect.repeat({
28 schedule: Schedule.spaced("25 millis"),
29 until: (alive) => !alive,
30 times: 120,
31 }),
32 Effect.flatMap((alive) =>
33 alive
34 ? Effect.fail(new Error(`Fixture PID ${pid} survived cleanup`))
35 : Effect.void,
36 ),
37 );
38
39export const pgid = (pid: number) =>
40 ChildProcess.make("ps", ["-o", "pgid=", "-p", String(pid)]).pipe(
41 Effect.flatMap((child) =>
42 child.stdout.pipe(Stream.decodeText, Stream.mkString),
43 ),
44 Effect.map((text) => Number(text.trim())),
45 Effect.scoped,
46 );
47
48export const lifecycleFixture = Effect.fn(function* (mode = "cooperative") {
49 const fs = yield* FileSystem.FileSystem;
50 const path = yield* Path.Path;
51 const directory = yield* fs.makeTempDirectoryScoped({
52 directory: "/tmp",
53 prefix: "command-lifecycle-",
54 });
55 const entry = yield* path.fromFileUrl(
56 new URL("./lifecycle.ts", import.meta.url),
57 );
58 const manifest = path.join(directory, "pids.json");
59 const read = fs
60 .readFileString(manifest)
61 .pipe(
62 Effect.map(
63 (text) => JSON.parse(text) as { wrapper: number; leaf: number },
64 ),
65 );
66 yield* Effect.addFinalizer(() =>
67 Effect.gen(function* () {
68 if (!(yield* fs.exists(manifest))) return;
69 const pids = yield* read;
70 yield* Effect.sync(() => {
71 for (const pid of [pids.leaf, pids.wrapper]) {
72 try {
73 process.kill(pid, "SIGKILL");
74 } catch {}
75 }
76 });
77 }).pipe(Effect.orDie),
78 );
79 const ready = fs.exists(path.join(directory, "wrapper.ready")).pipe(
80 Effect.repeat({
81 schedule: Schedule.spaced("25 millis"),
82 until: Boolean,
83 times: 300,
84 }),
85 Effect.andThen(read),
86 );
87 return {
88 directory,
89 entry,
90 ready,
91 props: {
92 command: `bun run ${entry}`,
93 env: { LIFECYCLE_DIR: directory, LIFECYCLE_MODE: mode },
94 },
95 crash: fs.writeFileString(path.join(directory, "crash"), "crash"),
96 has: (name: string) => fs.exists(path.join(directory, name)),
97 };
98});