1import { it } from "@effect/vitest";
2import * as Deferred from "effect/Deferred";
3import * as Duration from "effect/Duration";
4import * as Effect from "effect/Effect";
5import * as Fiber from "effect/Fiber";
6import * as Ref from "effect/Ref";
7import * as TestClock from "effect/testing/TestClock";
8import { expect } from "vite-plus/test";
9
10import * as OpenCodeRuntime from "./OpenCodeRuntime.ts";
11import * as OpenCodeServerOwner from "./OpenCodeServerOwner.ts";
12
13const unusedRuntimeMethod = () =>
14 Effect.fail(
15 new OpenCodeRuntime.OpenCodeRuntimeError({
16 operation: "unused",
17 detail: "unused test method",
18 }),
19 );
20
21const makeRuntime = Effect.gen(function* () {
22 const starts = yield* Ref.make(0);
23 const closes = yield* Ref.make(0);
24 const failNextStart = yield* Ref.make(false);
25 const started = yield* Deferred.make<void>();
26 const closed = yield* Deferred.make<void>();
27 const runtime: OpenCodeRuntime.OpenCodeRuntime["Service"] = {
28 startOpenCodeServerProcess: () =>
29 Effect.gen(function* () {
30 if (yield* Ref.getAndSet(failNextStart, false)) {
31 return yield* new OpenCodeRuntime.OpenCodeRuntimeError({
32 operation: "startOpenCodeServerProcess",
33 detail: "start failed",
34 });
35 }
36 const index = yield* Ref.updateAndGet(starts, (count) => count + 1);
37 yield* Deferred.succeed(started, undefined).pipe(Effect.ignore);
38 yield* Effect.addFinalizer(() =>
39 Ref.update(closes, (count) => count + 1).pipe(
40 Effect.andThen(Deferred.succeed(closed, undefined)),
41 Effect.ignore,
42 ),
43 );
44 return {
45 url: `http://127.0.0.1:${index}`,
46 version: "1.14.19",
47 isRunning: Effect.succeed(true),
48 exitCode: Effect.never,
49 };
50 }),
51 connectToOpenCodeServer: unusedRuntimeMethod,
52 runOpenCodeCommand: unusedRuntimeMethod,
53 createOpenCodeSdkClient: () => ({}) as never,
54 loadOpenCodeInventory: unusedRuntimeMethod,
55 loadOpenCodeSkills: unusedRuntimeMethod,
56 loadInventoryFromCli: unusedRuntimeMethod,
57 loadSkillsFromCli: unusedRuntimeMethod,
58 };
59 return { runtime, starts, closes, failNextStart, started, closed };
60});
61
62it.effect("shares concurrent borrowers and closes after the idle TTL", () =>
63 Effect.gen(function* () {
64 const testRuntime = yield* makeRuntime;
65 const release = yield* Deferred.make<void>();
66 yield* Effect.scoped(
67 Effect.gen(function* () {
68 const owner = yield* OpenCodeServerOwner.make({
69 binaryPath: "opencode",
70 directory: "/project",
71 });
72 const useServer = owner.withServer((server) =>
73 Deferred.await(release).pipe(Effect.as(server.url)),
74 );
75 const fibers = yield* Effect.all([useServer, useServer], {
76 concurrency: "unbounded",
77 }).pipe(Effect.forkChild);
78 yield* Deferred.await(testRuntime.started);
79 expect(yield* Ref.get(testRuntime.starts)).toBe(1);
80 yield* Deferred.succeed(release, undefined);
81 expect(yield* Fiber.join(fibers)).toEqual(["http://127.0.0.1:1", "http://127.0.0.1:1"]);
82 yield* TestClock.adjust(Duration.seconds(31));
83 yield* Deferred.await(testRuntime.closed);
84 expect(yield* Ref.get(testRuntime.closes)).toBe(1);
85 }),
86 ).pipe(Effect.provideService(OpenCodeRuntime.OpenCodeRuntime, testRuntime.runtime));
87 }).pipe(Effect.provide(TestClock.layer())),
88);
89
90it.effect("retries a failed start and closes on owner scope shutdown", () =>
91 Effect.gen(function* () {
92 const testRuntime = yield* makeRuntime;
93 yield* Ref.set(testRuntime.failNextStart, true);
94 yield* Effect.scoped(
95 Effect.gen(function* () {
96 const owner = yield* OpenCodeServerOwner.make({
97 binaryPath: "opencode",
98 directory: "/project",
99 });
100 expect(
101 (yield* Effect.exit(owner.withServer((server) => Effect.succeed(server.url))))._tag,
102 ).toBe("Failure");
103 expect(yield* owner.withServer((server) => Effect.succeed(server.url))).toBe(
104 "http://127.0.0.1:1",
105 );
106 }),
107 ).pipe(Effect.provideService(OpenCodeRuntime.OpenCodeRuntime, testRuntime.runtime));
108 expect(yield* Ref.get(testRuntime.starts)).toBe(1);
109 expect(yield* Ref.get(testRuntime.closes)).toBe(1);
110 }),
111);
112
113it.effect("invalidates an exited process so the next borrower starts a new one", () =>
114 Effect.gen(function* () {
115 const starts = yield* Ref.make(0);
116 const processExits: Array<Deferred.Deferred<number>> = [];
117 const processClosed = yield* Deferred.make<void>();
118 const runtime: OpenCodeRuntime.OpenCodeRuntime["Service"] = {
119 startOpenCodeServerProcess: () =>
120 Effect.gen(function* () {
121 const index = yield* Ref.updateAndGet(starts, (count) => count + 1);
122 const exitCode = yield* Deferred.make<number>();
123 processExits.push(exitCode);
124 yield* Effect.addFinalizer(() =>
125 Deferred.succeed(processClosed, undefined).pipe(Effect.ignore),
126 );
127 return {
128 url: `http://127.0.0.1:${index}`,
129 version: "1.14.19",
130 isRunning: Effect.succeed(true),
131 exitCode: Deferred.await(exitCode),
132 };
133 }),
134 connectToOpenCodeServer: unusedRuntimeMethod,
135 runOpenCodeCommand: unusedRuntimeMethod,
136 createOpenCodeSdkClient: () => ({}) as never,
137 loadOpenCodeInventory: unusedRuntimeMethod,
138 loadOpenCodeSkills: unusedRuntimeMethod,
139 loadInventoryFromCli: unusedRuntimeMethod,
140 loadSkillsFromCli: unusedRuntimeMethod,
141 };
142
143 yield* Effect.scoped(
144 Effect.gen(function* () {
145 const owner = yield* OpenCodeServerOwner.make({
146 binaryPath: "opencode",
147 directory: "/project",
148 });
149 expect(yield* owner.withServer((server) => Effect.succeed(server.url))).toBe(
150 "http://127.0.0.1:1",
151 );
152 yield* Deferred.succeed(processExits[0]!, 1);
153 yield* Deferred.await(processClosed);
154 expect(yield* owner.withServer((server) => Effect.succeed(server.url))).toBe(
155 "http://127.0.0.1:2",
156 );
157 }),
158 ).pipe(Effect.provideService(OpenCodeRuntime.OpenCodeRuntime, runtime));
159 expect(yield* Ref.get(starts)).toBe(2);
160 }),
161);
162
163it.effect("replaces a dead cached process before its exit watcher runs", () =>
164 Effect.gen(function* () {
165 const starts = yield* Ref.make(0);
166 const closes = yield* Ref.make(0);
167 const processRunning: Array<Ref.Ref<boolean>> = [];
168 const runtime: OpenCodeRuntime.OpenCodeRuntime["Service"] = {
169 startOpenCodeServerProcess: () =>
170 Effect.gen(function* () {
171 const index = yield* Ref.updateAndGet(starts, (count) => count + 1);
172 const isRunning = yield* Ref.make(true);
173 processRunning.push(isRunning);
174 yield* Effect.addFinalizer(() => Ref.update(closes, (count) => count + 1));
175 return {
176 url: `http://127.0.0.1:${index}`,
177 version: "1.14.19",
178 isRunning: Ref.get(isRunning),
179 exitCode: Effect.never,
180 };
181 }),
182 connectToOpenCodeServer: unusedRuntimeMethod,
183 runOpenCodeCommand: unusedRuntimeMethod,
184 createOpenCodeSdkClient: () => ({}) as never,
185 loadOpenCodeInventory: unusedRuntimeMethod,
186 loadOpenCodeSkills: unusedRuntimeMethod,
187 loadInventoryFromCli: unusedRuntimeMethod,
188 loadSkillsFromCli: unusedRuntimeMethod,
189 };
190
191 yield* Effect.scoped(
192 Effect.gen(function* () {
193 const owner = yield* OpenCodeServerOwner.make({
194 binaryPath: "opencode",
195 directory: "/project",
196 });
197 expect(yield* owner.withServer((server) => Effect.succeed(server.url))).toBe(
198 "http://127.0.0.1:1",
199 );
200 yield* Ref.set(processRunning[0]!, false);
201
202 expect(yield* owner.withServer((server) => Effect.succeed(server.url))).toBe(
203 "http://127.0.0.1:2",
204 );
205 expect(yield* Ref.get(starts)).toBe(2);
206 expect(yield* Ref.get(closes)).toBe(1);
207 }),
208 ).pipe(Effect.provideService(OpenCodeRuntime.OpenCodeRuntime, runtime));
209 }),
210);
211
212it.effect("cleans up an interrupted startup and allows a retry", () =>
213 Effect.gen(function* () {
214 const starts = yield* Ref.make(0);
215 const firstStartEntered = yield* Deferred.make<void>();
216 const firstStartClosed = yield* Deferred.make<void>();
217 const runtime: OpenCodeRuntime.OpenCodeRuntime["Service"] = {
218 startOpenCodeServerProcess: () =>
219 Effect.gen(function* () {
220 const index = yield* Ref.updateAndGet(starts, (count) => count + 1);
221 yield* Effect.addFinalizer(() =>
222 index === 1
223 ? Deferred.succeed(firstStartClosed, undefined).pipe(Effect.ignore)
224 : Effect.void,
225 );
226 if (index === 1) {
227 yield* Deferred.succeed(firstStartEntered, undefined);
228 return yield* Effect.never;
229 }
230 return {
231 url: `http://127.0.0.1:${index}`,
232 version: "1.14.19",
233 isRunning: Effect.succeed(true),
234 exitCode: Effect.never,
235 };
236 }),
237 connectToOpenCodeServer: unusedRuntimeMethod,
238 runOpenCodeCommand: unusedRuntimeMethod,
239 createOpenCodeSdkClient: () => ({}) as never,
240 loadOpenCodeInventory: unusedRuntimeMethod,
241 loadOpenCodeSkills: unusedRuntimeMethod,
242 loadInventoryFromCli: unusedRuntimeMethod,
243 loadSkillsFromCli: unusedRuntimeMethod,
244 };
245
246 yield* Effect.scoped(
247 Effect.gen(function* () {
248 const owner = yield* OpenCodeServerOwner.make({
249 binaryPath: "opencode",
250 directory: "/project",
251 });
252 const firstBorrower = yield* owner
253 .withServer((server) => Effect.succeed(server.url))
254 .pipe(Effect.forkChild);
255 yield* Deferred.await(firstStartEntered);
256 yield* Fiber.interrupt(firstBorrower);
257 yield* Deferred.await(firstStartClosed);
258 expect(yield* owner.withServer((server) => Effect.succeed(server.url))).toBe(
259 "http://127.0.0.1:2",
260 );
261 }),
262 ).pipe(Effect.provideService(OpenCodeRuntime.OpenCodeRuntime, runtime));
263 }),
264);
265
266it.effect("releases an interrupted borrower and closes after the idle TTL", () =>
267 Effect.gen(function* () {
268 const testRuntime = yield* makeRuntime;
269 const borrowerEntered = yield* Deferred.make<void>();
270 yield* Effect.scoped(
271 Effect.gen(function* () {
272 const owner = yield* OpenCodeServerOwner.make({
273 binaryPath: "opencode",
274 directory: "/project",
275 });
276 const borrower = yield* owner
277 .withServer(() =>
278 Deferred.succeed(borrowerEntered, undefined).pipe(Effect.andThen(Effect.never)),
279 )
280 .pipe(Effect.forkChild);
281 yield* Deferred.await(borrowerEntered);
282 yield* Fiber.interrupt(borrower);
283 yield* TestClock.adjust(Duration.seconds(31));
284 yield* Deferred.await(testRuntime.closed);
285 expect(yield* Ref.get(testRuntime.closes)).toBe(1);
286 }),
287 ).pipe(Effect.provideService(OpenCodeRuntime.OpenCodeRuntime, testRuntime.runtime));
288 }).pipe(Effect.provide(TestClock.layer())),
289);