1import * as NodeServices from "@effect/platform-node/NodeServices";
2import { expect, it } from "@effect/vitest";
3import { ServerSelfUpdateError, ThreadId } from "@t3tools/contracts";
4import * as HostProcess from "@t3tools/shared/HostProcess";
5import * as Cause from "effect/Cause";
6import * as Deferred from "effect/Deferred";
7import * as Effect from "effect/Effect";
8import * as FileSystem from "effect/FileSystem";
9import * as Fiber from "effect/Fiber";
10import * as Path from "effect/Path";
11import { HttpClient, HttpClientResponse } from "effect/http";
12import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner";
13
14import * as ServerConfig from "../config.ts";
15import * as DesktopAppUpdate from "../desktopUpdate/DesktopAppUpdate.ts";
16import * as ProcessRunner from "../processRunner.ts";
17import * as ServiceLauncherClient from "./serviceLauncherClient.ts";
18import { SERVICE_LAUNCHER_PROTOCOL } from "./serviceProtocol.ts";
19import * as ServerSelfUpdate from "./selfUpdate.ts";
20
21interface HarnessOptions {
22 readonly mode?: "web" | "desktop";
23 readonly managed?: boolean;
24 readonly preflight?: "ready" | "blocked";
25 readonly requestUpdate?: ServiceLauncherClient.ServiceLauncherClient["Service"]["requestUpdate"];
26 readonly desktopAppUpdate?: DesktopAppUpdate.DesktopAppUpdate["Service"];
27}
28
29// The staged runtime is a release archive: the fake client serves SHA256SUMS
30// and the tarball, and the fake runner stands in for tar before it answers
31// the staged preflight.
32const archiveBytes = new TextEncoder().encode("not really a tarball");
33const releaseHttpClient = (order: string[]) =>
34 HttpClient.make((request) =>
35 Effect.gen(function* () {
36 if (request.url.endsWith("/SHA256SUMS")) {
37 const digest = yield* Effect.promise(() => crypto.subtle.digest("SHA-256", archiveBytes));
38 const hex = Array.from(new Uint8Array(digest), (byte) =>
39 byte.toString(16).padStart(2, "0"),
40 ).join("");
41 return HttpClientResponse.fromWeb(
42 request,
43 new Response(`${hex} t3-1.1.0-linux-x64.tar.gz\n`),
44 );
45 }
46 order.push("download");
47 return HttpClientResponse.fromWeb(request, new Response(archiveBytes));
48 }),
49 );
50
51const makeHarness = Effect.fn("test.make_self_update_harness")(function* (
52 options: HarnessOptions = {},
53) {
54 const fs = yield* FileSystem.FileSystem;
55 const path = yield* Path.Path;
56 const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-self-update-test-" });
57 const order: string[] = [];
58 const runner = ProcessRunner.ProcessRunner.of({
59 run: (input) =>
60 Effect.gen(function* () {
61 if (input.command === "tar") {
62 order.push("extract");
63 const stagingDir = input.args[input.args.indexOf("-C") + 1];
64 if (stagingDir === undefined) return yield* Effect.die("missing tar target");
65 yield* fs.writeFileString(path.join(stagingDir, "t3"), "#!/bin/sh\n").pipe(Effect.orDie);
66 return {
67 stdout: "",
68 stderr: "",
69 code: ChildProcessSpawner.ExitCode(0),
70 timedOut: false,
71 stdoutTruncated: false,
72 stderrTruncated: false,
73 stdoutInvalidUtf8: false,
74 stderrInvalidUtf8: false,
75 };
76 }
77 order.push("preflight");
78 const result =
79 options.preflight === "blocked"
80 ? { status: "blocked", version: "1.1.0", reason: "local update required" }
81 : {
82 status: "ready",
83 version: "1.1.0",
84 launcherProtocol: SERVICE_LAUNCHER_PROTOCOL,
85 };
86 return {
87 stdout: JSON.stringify(result),
88 stderr: "",
89 code: ChildProcessSpawner.ExitCode(0),
90 timedOut: false,
91 stdoutTruncated: false,
92 stderrTruncated: false,
93 stdoutInvalidUtf8: false,
94 stderrInvalidUtf8: false,
95 };
96 }),
97 });
98 const launcher = ServiceLauncherClient.ServiceLauncherClient.of({
99 managed: options.managed ?? true,
100 requestUpdate:
101 options.requestUpdate ??
102 (() =>
103 Effect.sync(() => {
104 order.push("accept");
105 return "launcher-id";
106 })),
107 prepareTrial: Effect.undefined,
108 });
109 const config = yield* ServerConfig.ServerConfig.pipe(
110 Effect.provide(ServerConfig.layerTest(process.cwd(), baseDir)),
111 );
112 const selfUpdate = yield* ServerSelfUpdate.make().pipe(
113 Effect.provideService(ProcessRunner.ProcessRunner, runner),
114 Effect.provideService(ServiceLauncherClient.ServiceLauncherClient, launcher),
115 Effect.provideService(
116 DesktopAppUpdate.DesktopAppUpdate,
117 options.desktopAppUpdate ?? {
118 available: false,
119 run: () => Effect.die("unexpected desktop app update run"),
120 },
121 ),
122 Effect.provideService(HttpClient.HttpClient, releaseHttpClient(order)),
123 Effect.provideService(HostProcess.Platform, "linux"),
124 Effect.provideService(HostProcess.Architecture, "x64"),
125 Effect.provide(ServerConfig.layer({ ...config, mode: options.mode ?? "web" })),
126 );
127 return { selfUpdate, order };
128});
129
130it.layer(NodeServices.layer)("server self update", (it) => {
131 it.effect("marks running threads at the boot-service handoff", () =>
132 Effect.gen(function* () {
133 const events: string[] = [];
134 const selfUpdate = yield* ServerSelfUpdate.withRunningThreadContinuation({
135 mode: "web",
136 selfUpdate: {
137 update: (_input, reportProgress = () => Effect.void) =>
138 reportProgress("downloading").pipe(
139 Effect.andThen(reportProgress("installing")),
140 Effect.as({
141 targetVersion: "1.1.0",
142 method: "boot-service" as const,
143 updateId: "update-id",
144 }),
145 ),
146 commitDesktopUpdate: () => Effect.never,
147 },
148 prepare: Effect.sync(() => {
149 events.push("prepare");
150 return [ThreadId.make("thread-running")];
151 }),
152 clear: () => Effect.sync(() => void events.push("clear")),
153 });
154
155 yield* selfUpdate.update({ targetVersion: "1.1.0", continueRunningThreads: true }, (stage) =>
156 Effect.sync(() => void events.push(stage)),
157 );
158
159 expect(events).toEqual(["downloading", "prepare", "installing"]);
160 }),
161 );
162
163 it.effect("marks desktop threads only when the prepared update commits", () =>
164 Effect.gen(function* () {
165 const threadId = ThreadId.make("thread-running-desktop");
166 const events: string[] = [];
167 const commitError = new ServerSelfUpdateError({ reason: "install failed" });
168 const selfUpdate = yield* ServerSelfUpdate.withRunningThreadContinuation({
169 mode: "desktop",
170 selfUpdate: {
171 update: (_input, reportProgress = () => Effect.void) =>
172 reportProgress("installing").pipe(
173 Effect.as({
174 targetVersion: "1.2.0",
175 method: "desktop-app" as const,
176 desktopUpdateToken: "desktop-token",
177 }),
178 ),
179 commitDesktopUpdate: () =>
180 Effect.sync(() => events.push("commit")).pipe(Effect.andThen(Effect.fail(commitError))),
181 },
182 prepare: Effect.sync(() => {
183 events.push("prepare");
184 return [threadId];
185 }),
186 clear: (threadIds) => Effect.sync(() => void events.push(`clear:${threadIds.join(",")}`)),
187 });
188
189 yield* selfUpdate.update({ targetVersion: "1.2.0", continueRunningThreads: true }, (stage) =>
190 Effect.sync(() => void events.push(stage)),
191 );
192 expect(events).toEqual(["installing"]);
193 expect(yield* selfUpdate.commitDesktopUpdate("desktop-token").pipe(Effect.flip)).toBe(
194 commitError,
195 );
196 expect(events).toEqual(["installing", "prepare", "commit", `clear:${threadId}`]);
197 expect(yield* selfUpdate.commitDesktopUpdate("desktop-token").pipe(Effect.flip)).toBe(
198 commitError,
199 );
200 expect(events).toEqual([
201 "installing",
202 "prepare",
203 "commit",
204 `clear:${threadId}`,
205 "prepare",
206 "commit",
207 `clear:${threadId}`,
208 ]);
209 }),
210 );
211
212 it.effect("reports a failed continuation-marker cleanup", () =>
213 Effect.gen(function* () {
214 const updateError = new ServerSelfUpdateError({ reason: "update failed" });
215 const clearError = new ServerSelfUpdateError({ reason: "marker cleanup failed" });
216 const selfUpdate = yield* ServerSelfUpdate.withRunningThreadContinuation({
217 mode: "web",
218 selfUpdate: {
219 update: (_input, reportProgress = () => Effect.void) =>
220 reportProgress("installing").pipe(Effect.andThen(Effect.fail(updateError))),
221 commitDesktopUpdate: () => Effect.never,
222 },
223 prepare: Effect.succeed([ThreadId.make("thread-cleanup-failure")]),
224 clear: () => Effect.fail(clearError),
225 });
226
227 expect(
228 yield* selfUpdate
229 .update({ targetVersion: "1.1.0", continueRunningThreads: true })
230 .pipe(Effect.flip),
231 ).toBe(clearError);
232 }),
233 );
234
235 it.effect("keeps continuation markers after the boot-service handoff is accepted", () =>
236 Effect.gen(function* () {
237 const events: string[] = [];
238 const selfUpdate = yield* ServerSelfUpdate.withRunningThreadContinuation({
239 mode: "web",
240 selfUpdate: {
241 update: (
242 _input,
243 reportProgress = () => Effect.void,
244 onHandoffAccepted = () => Effect.void,
245 ) =>
246 reportProgress("installing").pipe(
247 Effect.andThen(onHandoffAccepted()),
248 Effect.andThen(Effect.interrupt),
249 ),
250 commitDesktopUpdate: () => Effect.never,
251 },
252 prepare: Effect.sync(() => {
253 events.push("prepare");
254 return [ThreadId.make("thread-accepted-boot-handoff")];
255 }),
256 clear: () => Effect.sync(() => void events.push("clear")),
257 });
258
259 const exit = yield* selfUpdate
260 .update({ targetVersion: "1.1.0", continueRunningThreads: true })
261 .pipe(Effect.exit);
262
263 expect(exit._tag).toBe("Failure");
264 expect(events).toEqual(["prepare"]);
265 }),
266 );
267
268 it.effect("keeps continuation markers after the desktop handoff is accepted", () =>
269 Effect.gen(function* () {
270 const events: string[] = [];
271 const selfUpdate = yield* ServerSelfUpdate.withRunningThreadContinuation({
272 mode: "desktop",
273 selfUpdate: {
274 update: () =>
275 Effect.succeed({
276 targetVersion: "1.2.0",
277 method: "desktop-app" as const,
278 desktopUpdateToken: "accepted-desktop-token",
279 }),
280 commitDesktopUpdate: (_requestId, onHandoffAccepted = () => Effect.void) =>
281 onHandoffAccepted().pipe(Effect.andThen(Effect.interrupt)),
282 },
283 prepare: Effect.sync(() => {
284 events.push("prepare");
285 return [ThreadId.make("thread-accepted-desktop-handoff")];
286 }),
287 clear: () => Effect.sync(() => void events.push("clear")),
288 });
289
290 yield* selfUpdate.update({
291 targetVersion: "1.2.0",
292 continueRunningThreads: true,
293 });
294 const exit = yield* selfUpdate
295 .commitDesktopUpdate("accepted-desktop-token")
296 .pipe(Effect.exit);
297
298 expect(exit._tag).toBe("Failure");
299 expect(events).toEqual(["prepare"]);
300 }),
301 );
302
303 it.effect("clears continuation markers for mixed failure and interrupt causes", () =>
304 Effect.gen(function* () {
305 const events: string[] = [];
306 const commitError = new ServerSelfUpdateError({ reason: "install failed" });
307 const selfUpdate = yield* ServerSelfUpdate.withRunningThreadContinuation({
308 mode: "desktop",
309 selfUpdate: {
310 update: () =>
311 Effect.succeed({
312 targetVersion: "1.2.0",
313 method: "desktop-app" as const,
314 desktopUpdateToken: "failed-desktop-token",
315 }),
316 commitDesktopUpdate: (_requestId, onHandoffAccepted = () => Effect.void) =>
317 onHandoffAccepted().pipe(
318 Effect.andThen(
319 Effect.failCause(
320 Cause.fromReasons([
321 Cause.makeFailReason(commitError),
322 Cause.makeInterruptReason(),
323 ]),
324 ),
325 ),
326 ),
327 },
328 prepare: Effect.sync(() => [ThreadId.make("thread-failed-desktop-install")]),
329 clear: () => Effect.sync(() => void events.push("clear")),
330 });
331
332 yield* selfUpdate.update({
333 targetVersion: "1.2.0",
334 continueRunningThreads: true,
335 });
336 const exit = yield* selfUpdate.commitDesktopUpdate("failed-desktop-token").pipe(Effect.exit);
337 expect(exit._tag).toBe("Failure");
338 if (exit._tag === "Failure") {
339 expect(Cause.hasInterrupts(exit.cause)).toBe(true);
340 expect(Cause.hasInterruptsOnly(exit.cause)).toBe(false);
341 }
342 expect(events).toEqual(["clear"]);
343 }),
344 );
345
346 it.effect("stages and preflights before asking the launcher for an update ID", () =>
347 Effect.gen(function* () {
348 const { selfUpdate, order } = yield* makeHarness();
349 expect(yield* selfUpdate.update({ targetVersion: "1.1.0" })).toEqual({
350 targetVersion: "1.1.0",
351 method: "boot-service",
352 updateId: "launcher-id",
353 });
354 expect(order).toEqual(["download", "extract", "preflight", "accept"]);
355 }),
356 );
357
358 it.effect("rejects invalid versions and desktop-managed servers before staging", () =>
359 Effect.gen(function* () {
360 const web = yield* makeHarness();
361 expect(
362 (yield* web.selfUpdate.update({ targetVersion: "latest" }).pipe(Effect.flip)).reason,
363 ).toBe("'latest' is not an exact t3 version.");
364 const desktop = yield* makeHarness({ mode: "desktop" });
365 expect(
366 (yield* desktop.selfUpdate.update({ targetVersion: "1.1.0" }).pipe(Effect.flip)).reason,
367 ).toContain("desktop app");
368 expect([...web.order, ...desktop.order]).toEqual([]);
369 }),
370 );
371
372 it.effect("delegates desktop-managed updates to the desktop app when available", () =>
373 Effect.gen(function* () {
374 const stages: string[] = [];
375 const { selfUpdate, order } = yield* makeHarness({
376 mode: "desktop",
377 desktopAppUpdate: {
378 available: true,
379 run: (reportProgress) =>
380 reportProgress("downloading").pipe(
381 Effect.andThen(reportProgress("installing")),
382 Effect.as({ targetVersion: "1.2.0", method: "desktop-app" as const }),
383 ),
384 commit: () => Effect.never,
385 },
386 });
387 const result = yield* selfUpdate.update({ targetVersion: "1.1.0" }, (stage) =>
388 Effect.sync(() => void stages.push(stage)),
389 );
390 expect(result).toEqual({ targetVersion: "1.2.0", method: "desktop-app" });
391 expect(stages).toEqual(["downloading", "installing"]);
392 // The launcher staging path must not run on the desktop path.
393 expect(order).toEqual([]);
394 }),
395 );
396
397 it.effect("preserves the preflight refusal reason", () =>
398 Effect.gen(function* () {
399 const { selfUpdate } = yield* makeHarness({ preflight: "blocked" });
400 expect((yield* selfUpdate.update({ targetVersion: "1.1.0" }).pipe(Effect.flip)).reason).toBe(
401 "local update required",
402 );
403 }),
404 );
405
406 it.effect("allows only one update at a time", () =>
407 Effect.gen(function* () {
408 const requested = yield* Deferred.make<void>();
409 const accepted = yield* Deferred.make<string>();
410 const { selfUpdate } = yield* makeHarness({
411 requestUpdate: () =>
412 Deferred.succeed(requested, undefined).pipe(Effect.andThen(Deferred.await(accepted))),
413 });
414 const first = yield* Effect.forkChild(selfUpdate.update({ targetVersion: "1.1.0" }), {
415 startImmediately: true,
416 });
417 yield* Deferred.await(requested);
418 expect((yield* selfUpdate.update({ targetVersion: "1.1.1" }).pipe(Effect.flip)).reason).toBe(
419 "A server update is already in progress.",
420 );
421 yield* Deferred.succeed(accepted, "launcher-id");
422 expect((yield* Fiber.join(first)).updateId).toBe("launcher-id");
423 }),
424 );
425});