Open History to see the last commit
1import * as NodeServices from "@effect/platform-node/NodeServices";
2import * as FileSystem from "effect/FileSystem";
3import * as Path from "effect/Path";
4import * as Logger from "effect/Logger";
5import * as PlatformError from "effect/PlatformError";
6import { assert, expect, it } from "@effect/vitest";
7import * as HostProcess from "@t3tools/shared/HostProcess";
8import * as Effect from "effect/Effect";
9import * as Fiber from "effect/Fiber";
10
11import {
12 SERVICE_LAUNCHER_CONTEXT_ENV,
13 SERVICE_LAUNCHER_PROTOCOL,
14 type ServiceLauncherChildMessage,
15 type ServiceLauncherParentMessage,
16} from "./serviceProtocol.ts";
17import * as ServiceLauncherClient from "./serviceLauncherClient.ts";
18
19class FakeLauncherProcess {
20 readonly connected: boolean;
21 readonly env: Record<string, string | undefined>;
22 readonly sent: ServiceLauncherChildMessage[] = [];
23 readonly #listeners = new Map<string, Set<(...args: ReadonlyArray<unknown>) => void>>();
24
25 constructor(context: unknown, connected = true) {
26 this.connected = connected;
27 this.env = { [SERVICE_LAUNCHER_CONTEXT_ENV]: JSON.stringify(context) };
28 }
29
30 send = (message: ServiceLauncherChildMessage, callback?: (error: Error | null) => void) => {
31 this.sent.push(message);
32 callback?.(null);
33 return true;
34 };
35
36 on = (event: "message" | "disconnect", listener: (...args: ReadonlyArray<unknown>) => void) => {
37 const listeners = this.#listeners.get(event) ?? new Set();
38 listeners.add(listener);
39 this.#listeners.set(event, listeners);
40 };
41
42 off = (event: "message" | "disconnect", listener: (...args: ReadonlyArray<unknown>) => void) => {
43 this.#listeners.get(event)?.delete(listener);
44 };
45
46 emit(message: ServiceLauncherParentMessage) {
47 for (const listener of this.#listeners.get("message") ?? []) listener(message);
48 }
49}
50
51const makeClient = (host: FakeLauncherProcess, currentVersion: string) =>
52 ServiceLauncherClient.make({ currentVersion }).pipe(
53 Effect.provideService(ServiceLauncherClient.ServiceLauncherHostProcess, host),
54 Effect.provideService(HostProcess.Environment, host.env),
55 Effect.provide(NodeServices.layer),
56 );
57
58// regression: #34502 a remote update never replaces the launcher, so an installed one stays protocol 3
59it.effect.each([3, SERVICE_LAUNCHER_PROTOCOL])(
60 "waits for a protocol-%i launcher to durably commit the trial update ID, then moves owned launchers",
61 (protocol) =>
62 Effect.gen(function* () {
63 const fs = yield* FileSystem.FileSystem;
64 const path = yield* Path.Path;
65 const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-trial-" });
66 const baseDir = path.join(root, ".t3");
67 const exe = (version: string) => path.join(baseDir, "runtime/versions", version, "t3");
68 const installed = path.join(root, ".local/bin/t3");
69 for (const file of [exe("1.0.0"), exe("1.1.0")]) {
70 yield* fs.makeDirectory(path.dirname(file), { recursive: true });
71 yield* fs.writeFileString(file, "");
72 }
73 yield* fs.makeDirectory(path.dirname(installed), { recursive: true });
74 yield* fs.symlink(exe("1.0.0"), installed);
75
76 const pending = {
77 id: "update-1",
78 fromVersion: "1.0.0",
79 targetVersion: "1.1.0",
80 dbPath: "/tmp/state.sqlite",
81 status: "pending" as const,
82 };
83 const host = new FakeLauncherProcess({ protocol, childVersion: "1.1.0", update: pending });
84 host.env["HOME"] = root;
85 const client = yield* makeClient(host, "1.1.0");
86 const prepared = yield* Effect.forkChild(client.prepareTrial({ baseDir }), {
87 startImmediately: true,
88 });
89 yield* Effect.yieldNow;
90 expect(host.sent).toEqual([{ type: "prepared", updateId: "update-1" }]);
91 assert.equal(yield* fs.readLink(installed), exe("1.0.0"));
92
93 const committed = {
94 id: pending.id,
95 fromVersion: pending.fromVersion,
96 targetVersion: pending.targetVersion,
97 status: "committed" as const,
98 };
99 host.emit({ type: "committed", updateId: committed.id });
100 expect(yield* Fiber.join(prepared)).toEqual(committed);
101 assert.equal(yield* fs.readLink(installed), exe("1.1.0"));
102
103 // A later start replays the stored outcome; user pins made since then stay put.
104 yield* fs.remove(installed);
105 yield* fs.symlink(exe("1.0.0"), installed);
106 const restarted = new FakeLauncherProcess({
107 protocol,
108 childVersion: "1.1.0",
109 update: committed,
110 });
111 restarted.env["HOME"] = root;
112 const restartedClient = yield* makeClient(restarted, "1.1.0");
113 expect(yield* restartedClient.prepareTrial({ baseDir })).toEqual(committed);
114 assert.equal(yield* fs.readLink(installed), exe("1.0.0"));
115 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
116);
117
118it.effect("returns the launcher-generated ID only after update acceptance", () =>
119 Effect.gen(function* () {
120 const host = new FakeLauncherProcess({
121 protocol: SERVICE_LAUNCHER_PROTOCOL,
122 childVersion: "1.0.0",
123 });
124 const client = yield* makeClient(host, "1.0.0");
125 const requested = yield* Effect.forkChild(
126 client.requestUpdate({ targetVersion: "1.1.0", dbPath: "/tmp/state.sqlite" }),
127 { startImmediately: true },
128 );
129 yield* Effect.yieldNow;
130 host.emit({
131 type: "update-accepted",
132 updateId: "launcher-id",
133 });
134 expect(yield* Fiber.join(requested)).toBe("launcher-id");
135 }),
136);
137
138it.effect("preserves a launcher rejection as a distinct error", () =>
139 Effect.gen(function* () {
140 const host = new FakeLauncherProcess({
141 protocol: SERVICE_LAUNCHER_PROTOCOL,
142 childVersion: "1.0.0",
143 });
144 const client = yield* makeClient(host, "1.0.0");
145 const requested = yield* Effect.forkChild(
146 client.requestUpdate({ targetVersion: "1.1.0", dbPath: "/tmp/state.sqlite" }),
147 { startImmediately: true },
148 );
149 yield* Effect.yieldNow;
150 host.emit({ type: "update-rejected", reason: "requires local update" });
151 expect(yield* Fiber.join(requested).pipe(Effect.flip)).toMatchObject({
152 _tag: "ServiceLauncherRejectedError",
153 targetVersion: "1.1.0",
154 reason: "requires local update",
155 });
156 }),
157);
158
159it.effect("stops the server when the launcher asks over IPC", () =>
160 Effect.gen(function* () {
161 const host = new FakeLauncherProcess({
162 protocol: SERVICE_LAUNCHER_PROTOCOL,
163 childVersion: "1.0.0",
164 });
165 const stopped = yield* Effect.forkChild(
166 ServiceLauncherClient.awaitServiceLauncherStop({ currentVersion: "1.0.0" }).pipe(
167 Effect.provideService(ServiceLauncherClient.ServiceLauncherHostProcess, host),
168 Effect.provideService(HostProcess.Environment, host.env),
169 ),
170 { startImmediately: true },
171 );
172 host.emit({ type: "stop" });
173 yield* Fiber.join(stopped);
174 }),
175);
176
177it.effect("rejects contradictory trial context instead of leaving activation closed", () =>
178 Effect.gen(function* () {
179 const host = new FakeLauncherProcess({
180 protocol: SERVICE_LAUNCHER_PROTOCOL,
181 childVersion: "1.1.0",
182 update: {
183 id: "update-1",
184 fromVersion: "1.0.0",
185 targetVersion: "1.2.0",
186 dbPath: "/tmp/state.sqlite",
187 status: "pending",
188 },
189 });
190 const error = yield* makeClient(host, "1.1.0").pipe(Effect.flip);
191 expect(error.message).toBe("The service launcher supplied invalid startup context.");
192 }),
193);
194
195it.effect("ignores launcher context inherited without an IPC channel", () =>
196 Effect.gen(function* () {
197 const host = new FakeLauncherProcess(
198 { protocol: SERVICE_LAUNCHER_PROTOCOL, childVersion: "1.0.0" },
199 false,
200 );
201 const client = yield* makeClient(host, "1.1.0");
202 const error = yield* client
203 .requestUpdate({ targetVersion: "1.2.0", dbPath: "/tmp/state.sqlite" })
204 .pipe(Effect.flip);
205 expect(error).toMatchObject({ _tag: "ServiceLauncherClientError", operation: "unmanaged" });
206 expect(host.sent).toEqual([]);
207 }),
208);
209
210it.layer(NodeServices.layer)("self-update launchers", (it) => {
211 it.effect(
212 "updates custom install and PATH launchers, including versions older than fromVersion",
213 () =>
214 Effect.gen(function* () {
215 const fs = yield* FileSystem.FileSystem;
216 const path = yield* Path.Path;
217 const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-update-" });
218 const baseDir = path.join(root, ".t3");
219 const exe = (version: string) => path.join(baseDir, "runtime/versions", version, "t3");
220 const installed = path.join(root, "custom/bin/t3");
221 const onPath = path.join(root, "bin/t3");
222 for (const file of [exe("1.0.0"), exe("1.5.0"), exe("2.0.0")]) {
223 yield* fs.makeDirectory(path.dirname(file), { recursive: true });
224 yield* fs.writeFileString(file, "");
225 }
226 yield* fs.makeDirectory(path.dirname(installed), { recursive: true });
227 yield* fs.symlink(exe("1.5.0"), installed);
228 yield* fs.makeDirectory(path.dirname(onPath), { recursive: true });
229 yield* fs.symlink(exe("1.0.0"), onPath);
230
231 yield* ServiceLauncherClient.repointLaunchers({ baseDir, targetVersion: "2.0.0" }).pipe(
232 Effect.provideService(HostProcess.Environment, {
233 HOME: root,
234 T3CODE_INSTALL_BIN_DIR: path.dirname(installed),
235 PATH: path.dirname(onPath),
236 }),
237 );
238
239 assert.equal(yield* fs.readLink(installed), exe("2.0.0"));
240 assert.equal(yield* fs.readLink(onPath), exe("2.0.0"));
241 }).pipe(Effect.scoped, Effect.provideService(HostProcess.Platform, "linux")),
242 );
243
244 it.effect("still moves the PATH launcher when the install bin one cannot be rewritten", () =>
245 Effect.gen(function* () {
246 const fs = yield* FileSystem.FileSystem;
247 const path = yield* Path.Path;
248 const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-update-" });
249 const baseDir = path.join(root, ".t3");
250 const exe = (version: string) => path.join(baseDir, "runtime/versions", version, "t3");
251 const installed = path.join(root, ".local/bin/t3");
252 const onPath = path.join(root, "bin/t3");
253 for (const file of [exe("1.0.0"), exe("2.0.0")]) {
254 yield* fs.makeDirectory(path.dirname(file), { recursive: true });
255 yield* fs.writeFileString(file, "");
256 }
257 for (const link of [installed, onPath]) {
258 yield* fs.makeDirectory(path.dirname(link), { recursive: true });
259 yield* fs.symlink(exe("1.0.0"), link);
260 }
261 const warnings: unknown[] = [];
262 const logger = Logger.make(({ logLevel, message }) => {
263 if (logLevel === "Warn") warnings.push(message);
264 });
265 const failingFs = FileSystem.FileSystem.of({
266 ...fs,
267 rename: (source, destination) =>
268 destination === installed
269 ? Effect.fail(
270 new PlatformError.PlatformError(
271 new PlatformError.SystemError({
272 _tag: "PermissionDenied",
273 module: "FileSystem",
274 method: "rename",
275 pathOrDescriptor: destination,
276 }),
277 ),
278 )
279 : fs.rename(source, destination),
280 });
281
282 yield* ServiceLauncherClient.repointLaunchers({ baseDir, targetVersion: "2.0.0" }).pipe(
283 Effect.provideService(HostProcess.Environment, {
284 HOME: root,
285 PATH: path.dirname(onPath),
286 }),
287 Effect.provideService(FileSystem.FileSystem, failingFs),
288 Effect.provide(Logger.layer([logger], { mergeWithExisting: false })),
289 );
290
291 assert.lengthOf(warnings, 1);
292 assert.include(JSON.stringify(warnings), installed);
293 assert.equal(yield* fs.readLink(installed), exe("1.0.0"));
294 assert.equal(yield* fs.readLink(onPath), exe("2.0.0"));
295 }).pipe(Effect.scoped, Effect.provideService(HostProcess.Platform, "linux")),
296 );
297
298 it.effect("leaves unowned install files and PATH symlinks untouched after self-update", () =>
299 Effect.gen(function* () {
300 const fs = yield* FileSystem.FileSystem;
301 const path = yield* Path.Path;
302 const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-update-" });
303 const baseDir = path.join(root, ".t3");
304 const installed = path.join(root, ".local/bin/t3");
305 const onPath = path.join(root, "bin/t3");
306 const foreign = path.join(root, "other-home/runtime/versions/1.0.0/t3");
307 const target = path.join(baseDir, "runtime/versions/2.0.0/t3");
308 for (const file of [installed, foreign, target]) {
309 yield* fs.makeDirectory(path.dirname(file), { recursive: true });
310 yield* fs.writeFileString(file, "user-owned file");
311 }
312 yield* fs.makeDirectory(path.dirname(onPath), { recursive: true });
313 yield* fs.symlink(foreign, onPath);
314
315 yield* ServiceLauncherClient.repointLaunchers({ baseDir, targetVersion: "2.0.0" }).pipe(
316 Effect.provideService(HostProcess.Environment, {
317 HOME: root,
318 PATH: path.dirname(onPath),
319 }),
320 );
321
322 assert.equal(yield* fs.readFileString(installed), "user-owned file");
323 assert.equal(yield* fs.readLink(onPath), foreign);
324 }).pipe(Effect.scoped, Effect.provideService(HostProcess.Platform, "linux")),
325 );
326});