1// @effect-diagnostics nodeBuiltinImport:off
2import * as NodeChildProcess from "node:child_process";
3
4import * as NodeServices from "@effect/platform-node/NodeServices";
5import { describe, expect, it } from "@effect/vitest";
6import * as HostProcess from "@t3tools/shared/HostProcess";
7import * as Deferred from "effect/Deferred";
8import * as Effect from "effect/Effect";
9import * as Exit from "effect/Exit";
10import * as Fiber from "effect/Fiber";
11import * as FileSystem from "effect/FileSystem";
12import * as Layer from "effect/Layer";
13import * as Path from "effect/Path";
14import * as Scope from "effect/Scope";
15import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner";
16
17import * as OpenCodeRuntime from "./OpenCodeRuntime.ts";
18import * as OpenCodeServerLedger from "./OpenCodeServerLedger.ts";
19
20const SERVE_ARGS = ["serve", "--hostname=127.0.0.1", "--port=4096"];
21
22const groupExists = (pgid: number) => {
23 try {
24 process.kill(-pgid, 0);
25 return true;
26 } catch {
27 return false;
28 }
29};
30
31const killGroup = (pgid: number) =>
32 Effect.sync(() => {
33 if (groupExists(pgid)) process.kill(-pgid, "SIGKILL");
34 });
35
36/**
37 * Spawns a throwaway process group shaped like `opencode serve`: by default a
38 * shell leader whose argv ends in the serve arguments, plus a `sleep` member.
39 */
40const spawnGroup = (args: ReadonlyArray<string>, script = "sleep 600 & wait") =>
41 Effect.acquireRelease(
42 Effect.gen(function* () {
43 const child = NodeChildProcess.spawn("/bin/sh", ["-c", script, ...args], {
44 detached: true,
45 stdio: "ignore",
46 });
47 const exited = yield* Deferred.make<NodeJS.Signals | null>();
48 child.once("exit", (_code, signal) => Deferred.doneUnsafe(exited, Effect.succeed(signal)));
49 const pid = child.pid;
50 if (pid === undefined) return yield* Effect.die("spawn failed");
51 return { pid, exited: Deferred.await(exited) };
52 }),
53 ({ pid }) => killGroup(pid),
54 );
55
56/** A T3 server that recorded its OpenCode server and then died without cleanup. */
57const recordFromDeadServer = (stateDir: string, server: { readonly pid: number }) =>
58 Effect.gen(function* () {
59 const previousServer = yield* spawnGroup([]);
60 const previousLedger = yield* OpenCodeServerLedger.make({
61 stateDir,
62 ownerPid: previousServer.pid,
63 });
64 // The previous server never gets to forget its entry.
65 yield* Effect.asVoid(previousLedger.track({ pid: server.pid, port: 4096, args: SERVE_ARGS }));
66 process.kill(-previousServer.pid, "SIGKILL");
67 yield* previousServer.exited;
68 });
69
70const hostPlatform = HostProcess.Platform.defaultValue();
71// procps accepts the same `ps` flags as macOS, so Linux also covers the macOS path.
72const observedPlatforms: ReadonlyArray<NodeJS.Platform> =
73 hostPlatform === "linux" ? ["linux", "darwin"] : hostPlatform === "darwin" ? ["darwin"] : [];
74
75describe.each(observedPlatforms)("OpenCodeServerLedger observing as %s", (platform) => {
76 const provideHost = <A, E, R>(effect: Effect.Effect<A, E, R>) =>
77 effect.pipe(
78 Effect.provideService(HostProcess.Platform, platform),
79 Effect.provide(NodeServices.layer),
80 );
81
82 it.live("stops an OpenCode server group left by a server that died", () =>
83 Effect.gen(function* () {
84 const fs = yield* FileSystem.FileSystem;
85 const path = yield* Path.Path;
86 const stateDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-opencode-ledger-" });
87 const orphan = yield* spawnGroup(SERVE_ARGS);
88 yield* recordFromDeadServer(stateDir, orphan);
89 expect(yield* fs.readDirectory(path.join(stateDir, "opencode-servers"))).toHaveLength(1);
90
91 const restarted = yield* OpenCodeServerLedger.make({ stateDir });
92 yield* restarted.reapOrphans;
93
94 expect(yield* orphan.exited).toBe("SIGTERM");
95 expect(groupExists(orphan.pid)).toBe(false);
96 expect(yield* fs.readDirectory(path.join(stateDir, "opencode-servers"))).toEqual([]);
97 }).pipe(provideHost),
98 );
99
100 it.live("records a group whose spawned wrapper already exited", () =>
101 Effect.gen(function* () {
102 const fs = yield* FileSystem.FileSystem;
103 const path = yield* Path.Path;
104 const stateDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-opencode-ledger-" });
105 // The wrapper starts the serve process in its group and exits.
106 const wrapper = yield* spawnGroup(
107 SERVE_ARGS,
108 '/bin/sh -c "sleep 600 & wait" "$0" "$@" & exit 0',
109 );
110 yield* wrapper.exited;
111 expect(groupExists(wrapper.pid)).toBe(true);
112
113 yield* recordFromDeadServer(stateDir, wrapper);
114 expect(yield* fs.readDirectory(path.join(stateDir, "opencode-servers"))).toHaveLength(1);
115
116 const restarted = yield* OpenCodeServerLedger.make({ stateDir });
117 yield* restarted.reapOrphans;
118
119 expect(groupExists(wrapper.pid)).toBe(false);
120 expect(yield* fs.readDirectory(path.join(stateDir, "opencode-servers"))).toEqual([]);
121 }).pipe(provideHost),
122 );
123
124 it.live("leaves a recycled pid alone when its start time does not match", () =>
125 Effect.gen(function* () {
126 const fs = yield* FileSystem.FileSystem;
127 const path = yield* Path.Path;
128 const stateDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-opencode-ledger-" });
129 const unrelated = yield* spawnGroup(SERVE_ARGS);
130 yield* recordFromDeadServer(stateDir, unrelated);
131 const entryPath = path.join(stateDir, "opencode-servers", `${unrelated.pid}.json`);
132 // Same pid, a different process: what a recycled pid looks like.
133 const entry = yield* fs.readFileString(entryPath);
134 yield* fs.writeFileString(entryPath, entry.replace(/"startTime":"[^"]+"/, '"startTime":"1"'));
135
136 const restarted = yield* OpenCodeServerLedger.make({ stateDir });
137 yield* restarted.reapOrphans;
138
139 expect(groupExists(unrelated.pid)).toBe(true);
140 expect(yield* fs.exists(entryPath)).toBe(false);
141 }).pipe(provideHost),
142 );
143
144 it.live("leaves a serve process on another port alone", () =>
145 Effect.gen(function* () {
146 const fs = yield* FileSystem.FileSystem;
147 const path = yield* Path.Path;
148 const stateDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-opencode-ledger-" });
149 const unrelated = yield* spawnGroup(SERVE_ARGS);
150 yield* recordFromDeadServer(stateDir, unrelated);
151 const entryPath = path.join(stateDir, "opencode-servers", `${unrelated.pid}.json`);
152 // Same pid and start second, but not the server T3 started.
153 const entry = yield* fs.readFileString(entryPath);
154 yield* fs.writeFileString(entryPath, entry.replace("--port=4096", "--port=4097"));
155
156 const restarted = yield* OpenCodeServerLedger.make({ stateDir });
157 yield* restarted.reapOrphans;
158
159 expect(groupExists(unrelated.pid)).toBe(true);
160 expect(yield* fs.exists(entryPath)).toBe(false);
161 }).pipe(provideHost),
162 );
163
164 it.live("leaves the servers of a running T3 server alone", () =>
165 Effect.gen(function* () {
166 const fs = yield* FileSystem.FileSystem;
167 const path = yield* Path.Path;
168 const stateDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-opencode-ledger-" });
169 const running = yield* OpenCodeServerLedger.make({ stateDir });
170 const server = yield* spawnGroup(SERVE_ARGS);
171 const forget = yield* running.track({ pid: server.pid, port: 4096, args: SERVE_ARGS });
172
173 const other = yield* OpenCodeServerLedger.make({ stateDir });
174 yield* other.reapOrphans;
175 expect(groupExists(server.pid)).toBe(true);
176
177 yield* forget;
178 expect(yield* fs.readDirectory(path.join(stateDir, "opencode-servers"))).toEqual([]);
179 }).pipe(provideHost),
180 );
181});
182
183describe.skipIf(observedPlatforms.length === 0)("OpenCode server startup", () => {
184 it.live("stops the group when interrupted while the server is being recorded", () =>
185 Effect.gen(function* () {
186 const fs = yield* FileSystem.FileSystem;
187 const path = yield* Path.Path;
188 const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-opencode-startup-" });
189 // A wrapper that leaves the serve process running in its group and exits 0,
190 // which the spawner's own cleanup does not stop.
191 const binaryPath = path.join(tempDir, "opencode");
192 yield* fs.writeFileString(binaryPath, "#!/bin/sh\nsleep 600 &\nexit 0\n");
193 yield* fs.chmod(binaryPath, 0o755);
194
195 const spawned = yield* Deferred.make<ChildProcessSpawner.ChildProcessHandle>();
196 const recording = yield* Deferred.make<number>();
197 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
198 const layerRuntime = OpenCodeRuntime.layer.pipe(
199 Layer.provide(
200 Layer.succeed(
201 ChildProcessSpawner.ChildProcessSpawner,
202 ChildProcessSpawner.make((command) =>
203 spawner
204 .spawn(command)
205 .pipe(Effect.tap((handle) => Deferred.succeed(spawned, handle))),
206 ),
207 ),
208 ),
209 Layer.provide(
210 Layer.succeed(
211 OpenCodeServerLedger.OpenCodeServerLedger,
212 OpenCodeServerLedger.OpenCodeServerLedger.of({
213 // Recording stalls until the scope is interrupted, after the wrapper exited.
214 track: (server) =>
215 Effect.gen(function* () {
216 yield* (yield* Deferred.await(spawned)).exitCode.pipe(Effect.ignore);
217 yield* Deferred.succeed(recording, server.pid);
218 return yield* Effect.never;
219 }),
220 }),
221 ),
222 ),
223 );
224
225 const serverScope = yield* Scope.make();
226 const starting = yield* OpenCodeRuntime.OpenCodeRuntime.pipe(
227 Effect.flatMap((runtime) =>
228 runtime.startOpenCodeServerProcess({ binaryPath, directory: tempDir, port: 4096 }),
229 ),
230 Effect.provideService(Scope.Scope, serverScope),
231 Effect.provide(layerRuntime),
232 Effect.forkChild,
233 );
234 const pgid = yield* Deferred.await(recording);
235 yield* Effect.addFinalizer(() => killGroup(pgid));
236 expect(groupExists(pgid)).toBe(true);
237
238 yield* Fiber.interrupt(starting);
239 yield* Scope.close(serverScope, Exit.void);
240
241 expect(groupExists(pgid)).toBe(false);
242 }).pipe(Effect.provide(NodeServices.layer)),
243 );
244});