1import { assert, describe, it } from "@effect/vitest";
2import * as NodeServices from "@effect/platform-node/NodeServices";
3import * as Effect from "effect/Effect";
4import * as FileSystem from "effect/FileSystem";
5import * as Layer from "effect/Layer";
6import * as Logger from "effect/Logger";
7import * as Option from "effect/Option";
8import * as Path from "effect/Path";
9import * as References from "effect/References";
10import * as Schema from "effect/Schema";
11
12import * as ProcessRunner from "./processRunner.ts";
13import * as ServerOwnership from "./serverOwnership.ts";
14import * as ServerRuntimeState from "./serverRuntimeState.ts";
15
16const isServerRuntimeStateError = Schema.is(ServerRuntimeState.ServerRuntimeStateError);
17
18interface CapturedLog {
19 readonly message: unknown;
20 readonly annotations: Readonly<Record<string, unknown>>;
21}
22
23describe("server ownership", () => {
24 const layerOwnership = ProcessRunner.layer.pipe(Layer.provideMerge(NodeServices.layer));
25 const makeState = ServerRuntimeState.makePersistedServerRuntimeState({
26 config: { host: undefined, devUrl: undefined },
27 port: 3773,
28 });
29
30 it.effect("lets only one of two racing starts own a state directory", () =>
31 Effect.gen(function* () {
32 const fs = yield* FileSystem.FileSystem;
33 const path = yield* Path.Path;
34 const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-owner-race-" });
35 const statePath = path.join(root, "server-runtime.json");
36 const attempts = yield* Effect.all(
37 [
38 ServerOwnership.acquireServerOwnership(statePath).pipe(Effect.result),
39 ServerOwnership.acquireServerOwnership(statePath).pipe(Effect.result),
40 ],
41 { concurrency: "unbounded" },
42 );
43 assert.equal(attempts.filter((result) => result._tag === "Success").length, 1);
44 const refused = attempts.find((result) => result._tag === "Failure");
45 assert.equal(refused?.failure._tag, "ServerAlreadyRunningError");
46 }).pipe(Effect.provide(layerOwnership)),
47 );
48
49 it.effect("refuses a second owner without touching its record, then allows a restart", () =>
50 Effect.gen(function* () {
51 const fs = yield* FileSystem.FileSystem;
52 const path = yield* Path.Path;
53 const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-owner-" });
54 const statePath = path.join(root, "server-runtime.json");
55 const state = yield* makeState;
56 yield* Effect.scoped(
57 Effect.gen(function* () {
58 const owner = yield* ServerOwnership.acquireServerOwnership(statePath);
59 yield* owner.publish(state);
60 const before = yield* fs.readFileString(statePath);
61 const refused = yield* ServerOwnership.acquireServerOwnership(statePath).pipe(
62 Effect.flip,
63 );
64 assert.equal(refused._tag, "ServerAlreadyRunningError");
65 assert.equal(yield* fs.readFileString(statePath), before);
66 }),
67 );
68 assert.isFalse(yield* fs.exists(statePath));
69 yield* Effect.scoped(ServerOwnership.acquireServerOwnership(statePath));
70 }).pipe(Effect.provide(layerOwnership)),
71 );
72
73 it.effect("refuses beside a live pre-lock server and ignores a stale record", () =>
74 Effect.gen(function* () {
75 const fs = yield* FileSystem.FileSystem;
76 const path = yield* Path.Path;
77 const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-owner-legacy-" });
78 const statePath = path.join(root, "server-runtime.json");
79 const state = yield* makeState;
80 yield* ServerRuntimeState.persistServerRuntimeState({
81 path: statePath,
82 // @effect-diagnostics-next-line globalDateInEffect:off - Legacy identity uses the real process start time, not TestClock.
83 state: { ...state, startedAt: new Date().toISOString() },
84 });
85 const refused = yield* Effect.scoped(ServerOwnership.acquireServerOwnership(statePath)).pipe(
86 Effect.flip,
87 );
88 assert.equal(refused._tag, "ServerAlreadyRunningError");
89 yield* ServerRuntimeState.persistServerRuntimeState({
90 path: statePath,
91 state: { ...state, pid: 0 },
92 });
93 yield* Effect.scoped(ServerOwnership.acquireServerOwnership(statePath));
94 }).pipe(Effect.provide(layerOwnership)),
95 );
96
97 it.effect("never removes a runtime record another server published", () =>
98 Effect.gen(function* () {
99 const fs = yield* FileSystem.FileSystem;
100 const path = yield* Path.Path;
101 const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-owner-cleanup-" });
102 const statePath = path.join(root, "server-runtime.json");
103 const state = yield* makeState;
104 yield* Effect.scoped(
105 Effect.gen(function* () {
106 const owner = yield* ServerOwnership.acquireServerOwnership(statePath);
107 yield* owner.publish(state);
108 yield* ServerRuntimeState.persistServerRuntimeState({
109 path: statePath,
110 state: { ...state, ownerId: "other-owner" },
111 });
112 }),
113 );
114 const remaining = yield* ServerRuntimeState.readPersistedServerRuntimeState(statePath);
115 assert.equal(Option.getOrThrow(remaining).ownerId, "other-owner");
116 }).pipe(Effect.provide(layerOwnership)),
117 );
118});
119
120describe("serverRuntimeState", () => {
121 it.effect("persists and reads the runtime state", () =>
122 Effect.gen(function* () {
123 const fileSystem = yield* FileSystem.FileSystem;
124 const path = yield* Path.Path;
125 const root = yield* fileSystem.makeTempDirectoryScoped({
126 prefix: "t3-server-runtime-state-test-",
127 });
128 const statePath = path.join(root, "runtime", "server.json");
129 const state: ServerRuntimeState.PersistedServerRuntimeState = {
130 version: 1,
131 pid: 123,
132 host: "127.0.0.1",
133 port: 4_971,
134 origin: "http://127.0.0.1:4971",
135 devUrl: "http://localhost:5733/",
136 startedAt: "2026-06-20T00:00:00.000Z",
137 };
138
139 yield* ServerRuntimeState.persistServerRuntimeState({ path: statePath, state });
140 const restored = yield* ServerRuntimeState.readPersistedServerRuntimeState(statePath);
141
142 assert.deepEqual(Option.getOrThrow(restored), state);
143 }).pipe(Effect.provide(NodeServices.layer)),
144 );
145
146 it.effect("records the dev web URL when the server fronts a dev server", () =>
147 Effect.gen(function* () {
148 const state = yield* ServerRuntimeState.makePersistedServerRuntimeState({
149 config: { host: undefined, devUrl: new URL("http://localhost:5733") },
150 port: 13_773,
151 });
152
153 assert.equal(state.devUrl, "http://localhost:5733/");
154 assert.equal(state.origin, "http://127.0.0.1:13773");
155
156 const withoutDev = yield* ServerRuntimeState.makePersistedServerRuntimeState({
157 config: { host: undefined, devUrl: undefined },
158 port: 13_773,
159 });
160 assert.isFalse("devUrl" in withoutDev);
161 }),
162 );
163
164 it.effect("marks a service-supervised server so CLIs can tell it from a manual one", () =>
165 Effect.gen(function* () {
166 const managed = yield* ServerRuntimeState.makePersistedServerRuntimeState({
167 config: { host: undefined, devUrl: undefined },
168 port: 13_773,
169 serviceManaged: true,
170 });
171 const manual = yield* ServerRuntimeState.makePersistedServerRuntimeState({
172 config: { host: undefined, devUrl: undefined },
173 port: 13_773,
174 });
175
176 assert.isTrue(managed.serviceManaged);
177 // Older readers decode the file without the field, so it is omitted
178 // rather than written as false.
179 assert.isFalse("serviceManaged" in manual);
180 }),
181 );
182
183 it.effect("treats a missing runtime state file as absent", () =>
184 Effect.gen(function* () {
185 const fileSystem = yield* FileSystem.FileSystem;
186 const path = yield* Path.Path;
187 const root = yield* fileSystem.makeTempDirectoryScoped({
188 prefix: "t3-server-runtime-state-test-",
189 });
190
191 const restored = yield* ServerRuntimeState.readPersistedServerRuntimeState(
192 path.join(root, "missing.json"),
193 );
194
195 assert.isTrue(Option.isNone(restored));
196 }).pipe(Effect.provide(NodeServices.layer)),
197 );
198
199 it.effect("preserves malformed state decode failures", () => {
200 const logs: CapturedLog[] = [];
201 const logger = Logger.make(({ fiber, message }) => {
202 logs.push({
203 message,
204 annotations: fiber.getRef(References.CurrentLogAnnotations),
205 });
206 });
207
208 return Effect.gen(function* () {
209 const fileSystem = yield* FileSystem.FileSystem;
210 const path = yield* Path.Path;
211 const root = yield* fileSystem.makeTempDirectoryScoped({
212 prefix: "t3-server-runtime-state-test-",
213 });
214 const statePath = path.join(root, "server.json");
215 yield* fileSystem.writeFileString(statePath, "{not json");
216
217 const restored = yield* ServerRuntimeState.readPersistedServerRuntimeState(statePath);
218
219 assert.isTrue(Option.isNone(restored));
220 assert.equal(logs[0]?.message, `Failed to decode server runtime state at ${statePath}.`);
221 const error = logs[0]?.annotations.cause;
222 assert.isTrue(isServerRuntimeStateError(error));
223 if (isServerRuntimeStateError(error)) {
224 assert.equal(error.operation, "decode");
225 assert.equal(error.statePath, statePath);
226 assert.equal(error.message, `Failed to decode server runtime state at ${statePath}.`);
227 assert.deepInclude(error.cause, { _tag: "SchemaError" });
228 }
229 }).pipe(
230 Effect.provide(
231 Layer.merge(NodeServices.layer, Logger.layer([logger], { mergeWithExisting: false })),
232 ),
233 );
234 });
235
236 it.effect("preserves runtime state read failures", () => {
237 const logs: CapturedLog[] = [];
238 const logger = Logger.make(({ fiber, message }) => {
239 logs.push({
240 message,
241 annotations: fiber.getRef(References.CurrentLogAnnotations),
242 });
243 });
244
245 return Effect.gen(function* () {
246 const fileSystem = yield* FileSystem.FileSystem;
247 const path = yield* Path.Path;
248 const root = yield* fileSystem.makeTempDirectoryScoped({
249 prefix: "t3-server-runtime-state-test-",
250 });
251 const statePath = path.join(root, "server.json");
252 yield* fileSystem.makeDirectory(statePath);
253
254 const restored = yield* ServerRuntimeState.readPersistedServerRuntimeState(statePath);
255
256 assert.isTrue(Option.isNone(restored));
257 assert.equal(logs[0]?.message, `Failed to read server runtime state at ${statePath}.`);
258 const error = logs[0]?.annotations.cause;
259 assert.isTrue(isServerRuntimeStateError(error));
260 if (isServerRuntimeStateError(error)) {
261 assert.equal(error.operation, "read");
262 assert.equal(error.statePath, statePath);
263 assert.equal(error.message, `Failed to read server runtime state at ${statePath}.`);
264 assert.deepInclude(error.cause, { _tag: "PlatformError" });
265 }
266 }).pipe(
267 Effect.provide(
268 Layer.merge(NodeServices.layer, Logger.layer([logger], { mergeWithExisting: false })),
269 ),
270 );
271 });
272
273 it.effect("preserves runtime state persistence failures", () =>
274 Effect.gen(function* () {
275 const fileSystem = yield* FileSystem.FileSystem;
276 const path = yield* Path.Path;
277 const root = yield* fileSystem.makeTempDirectoryScoped({
278 prefix: "t3-server-runtime-state-test-",
279 });
280 const blockedDirectory = path.join(root, "not-a-directory");
281 const statePath = path.join(blockedDirectory, "server.json");
282 yield* fileSystem.writeFileString(blockedDirectory, "blocked");
283
284 const error = yield* ServerRuntimeState.persistServerRuntimeState({
285 path: statePath,
286 state: {
287 version: 1,
288 pid: 123,
289 port: 4_971,
290 origin: "http://127.0.0.1:4971",
291 startedAt: "2026-06-20T00:00:00.000Z",
292 },
293 }).pipe(Effect.flip);
294
295 assert.isTrue(isServerRuntimeStateError(error));
296 if (isServerRuntimeStateError(error)) {
297 assert.equal(error.operation, "persist");
298 assert.equal(error.statePath, statePath);
299 assert.equal(error.message, `Failed to persist server runtime state at ${statePath}.`);
300 assert.deepInclude(error.cause, { _tag: "PlatformError" });
301 }
302 }).pipe(Effect.provide(NodeServices.layer)),
303 );
304});