Open History to see the last commit
1// @effect-diagnostics nodeBuiltinImport:off - node:path.win32 stands in for a Windows host in one test.
2import * as NodeServices from "@effect/platform-node/NodeServices";
3import { assert, it } from "@effect/vitest";
4import * as Deferred from "effect/Deferred";
5import * as Duration from "effect/Duration";
6import * as Effect from "effect/Effect";
7import * as FileSystem from "effect/FileSystem";
8import * as Fiber from "effect/Fiber";
9import * as Path from "effect/Path";
10import * as Stream from "effect/Stream";
11import * as NodePath from "node:path";
12import * as TestClock from "effect/testing/TestClock";
13import { HttpClient, HttpClientError, HttpClientResponse } from "effect/http";
14import * as ChildProcess from "effect/process/ChildProcess";
15import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner";
16
17import * as HostProcess from "@t3tools/shared/HostProcess";
18
19import * as ProcessRunner from "../processRunner.ts";
20import {
21 isVersionExecutable,
22 ensurePinnedRuntimeInstalled,
23 isUnpublishedRelease,
24 pinnedRuntimeCommand,
25 pinnedRuntimePaths,
26 PinnedRuntimeInstallError,
27 type PinnedRuntimeProgress,
28 prunePinnedRuntimes,
29} from "./pinnedRuntime.ts";
30import { SERVICE_LAUNCHER_PROTOCOL } from "./serviceProtocol.ts";
31
32const version = "1.2.3";
33const archiveName = `t3-${version}-linux-x64.tar.gz`;
34const archiveBytes = new TextEncoder().encode("not really a tarball");
35const archiveHex = (bytes: Uint8Array) =>
36 Effect.promise(() => crypto.subtle.digest("SHA-256", bytes)).pipe(
37 Effect.map((digest) =>
38 Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join(""),
39 ),
40 );
41const validChecksums = archiveHex(archiveBytes).pipe(
42 Effect.map((hex) => `${hex} ${archiveName}\n`),
43);
44const releaseHttpClient = (checksums: string, requests: string[] = []) =>
45 HttpClient.make((request) => {
46 requests.push(request.url);
47 const body = new URL(request.url).pathname.endsWith("/SHA256SUMS") ? checksums : archiveBytes;
48 return Effect.succeed(HttpClientResponse.fromWeb(request, new Response(body)));
49 });
50const extractingRunner = (fs: FileSystem.FileSystem, path: Path.Path, commands: string[] = []) =>
51 ProcessRunner.ProcessRunner.of({
52 run: (input) =>
53 Effect.gen(function* () {
54 commands.push(input.command);
55 const targetIndex = input.args.indexOf("-C");
56 const stagingDir = input.args[targetIndex + 1];
57 if (input.command !== "tar" || stagingDir === undefined) {
58 return yield* Effect.die(`unexpected command ${input.command}`);
59 }
60 yield* fs.writeFileString(path.join(stagingDir, "t3"), "#!/bin/sh\n").pipe(Effect.orDie);
61 return {
62 stdout: "",
63 stderr: "",
64 code: ChildProcessSpawner.ExitCode(0),
65 timedOut: false,
66 stdoutTruncated: false,
67 stderrTruncated: false,
68 stdoutInvalidUtf8: false,
69 stderrInvalidUtf8: false,
70 };
71 }),
72 });
73
74it.layer(NodeServices.layer)("ensurePinnedRuntimeInstalled", (it) => {
75 it.effect("installs the verified release archive as the runtime executable", () =>
76 Effect.gen(function* () {
77 const fs = yield* FileSystem.FileSystem;
78 const path = yield* Path.Path;
79 const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-pinned-archive-" });
80 const requests: string[] = [];
81 const commands: string[] = [];
82 const paths = yield* ensurePinnedRuntimeInstalled({
83 baseDir,
84 version,
85 fs,
86 path,
87 platform: "linux",
88 arch: "x64",
89 httpClient: releaseHttpClient(yield* validChecksums, requests),
90 releaseBaseUrl: "https://releases.example/download",
91 runner: extractingRunner(fs, path, commands),
92 validate: (staging) =>
93 fs.exists(staging.entryPath).pipe(
94 Effect.flatMap((exists) => (exists ? Effect.void : Effect.die("missing runtime"))),
95 Effect.orDie,
96 ),
97 });
98 assert.equal(paths.entryPath, path.join(paths.versionDir, "t3"));
99 assert.deepEqual(pinnedRuntimeCommand(paths), { command: paths.entryPath, args: [] });
100 assert.deepEqual(requests, [
101 `https://releases.example/download/v${version}/SHA256SUMS`,
102 `https://releases.example/download/v${version}/${archiveName}`,
103 ]);
104 assert.deepEqual(commands, ["tar"]);
105 assert.equal(yield* fs.readFileString(paths.sentinelPath), `${version}\n`);
106 assert.isFalse(yield* fs.exists(path.join(paths.versionDir, "t3-runtime-archive")));
107 }),
108 );
109
110 it.effect("cleans up an interrupted download without reporting verification or extraction", () =>
111 Effect.gen(function* () {
112 const fs = yield* FileSystem.FileSystem;
113 const path = yield* Path.Path;
114 const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-pinned-progress-failed-" });
115 const checksums = yield* validChecksums;
116 const progress: PinnedRuntimeProgress[] = [];
117 let cancelled = false;
118 const client = HttpClient.make((request) =>
119 Effect.succeed(
120 HttpClientResponse.fromWeb(
121 request,
122 request.url.endsWith("/SHA256SUMS")
123 ? new Response(checksums)
124 : new Response(
125 new ReadableStream({
126 start(controller) {
127 controller.enqueue(archiveBytes.slice(0, 4));
128 },
129 cancel() {
130 cancelled = true;
131 },
132 }),
133 ),
134 ),
135 ),
136 );
137 const firstChunk = yield* Deferred.make<void>();
138 const install = yield* ensurePinnedRuntimeInstalled({
139 baseDir,
140 version,
141 fs,
142 path,
143 platform: "linux",
144 arch: "x64",
145 httpClient: client,
146 runner: extractingRunner(fs, path),
147 validate: () => Effect.die("must not validate an interrupted archive"),
148 onProgress: (event) => {
149 progress.push(event);
150 if (event.stage === "download" && event.received === 4)
151 Deferred.doneUnsafe(firstChunk, Effect.void);
152 },
153 }).pipe(Effect.forkScoped);
154 yield* Deferred.await(firstChunk);
155 yield* Fiber.interrupt(install);
156 assert.deepEqual(progress.at(-1), { stage: "download", received: 4, total: undefined });
157 assert.isTrue(progress.every((event) => event.stage === "download"));
158 assert.isTrue(cancelled);
159 assert.deepEqual(yield* fs.readDirectory(path.join(baseDir, "runtime", "versions")), []);
160 }),
161 );
162
163 it.effect("reports a release that lists an archive it does not serve as unpublished", () =>
164 Effect.gen(function* () {
165 const fs = yield* FileSystem.FileSystem;
166 const path = yield* Path.Path;
167 const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-pinned-archive-missing-" });
168 const checksums = yield* validChecksums;
169 const failsWith = (status: number) =>
170 ensurePinnedRuntimeInstalled({
171 baseDir,
172 version,
173 fs,
174 path,
175 platform: "linux",
176 arch: "x64",
177 httpClient: HttpClient.make((request) =>
178 Effect.succeed(
179 HttpClientResponse.fromWeb(
180 request,
181 request.url.endsWith("/SHA256SUMS")
182 ? new Response(checksums)
183 : new Response("", { status }),
184 ),
185 ),
186 ),
187 // "404" in the mirror's path must not read a server error as a missing release.
188 releaseBaseUrl: "https://updates.example.com/404/t3code",
189 runner: extractingRunner(fs, path),
190 validate: () => Effect.die("must not validate a missing archive"),
191 }).pipe(Effect.flip);
192 assert.isTrue(isUnpublishedRelease(yield* failsWith(404)));
193 assert.isFalse(isUnpublishedRelease(yield* failsWith(403)));
194 }),
195 );
196
197 it.effect("retries a release download that GitHub answers with a gateway timeout", () =>
198 Effect.gen(function* () {
199 const fs = yield* FileSystem.FileSystem;
200 const path = yield* Path.Path;
201 const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-pinned-retry-" });
202 const checksums = yield* validChecksums;
203 const requests: string[] = [];
204 const gatewayTimeoutServed = yield* Deferred.make<void>();
205 const install = yield* ensurePinnedRuntimeInstalled({
206 baseDir,
207 version,
208 fs,
209 path,
210 platform: "linux",
211 arch: "x64",
212 httpClient: HttpClient.make((request) => {
213 requests.push(request.url);
214 const isChecksums = request.url.endsWith("/SHA256SUMS");
215 if (requests.length === 1) {
216 Deferred.doneUnsafe(gatewayTimeoutServed, Effect.void);
217 return Effect.succeed(
218 HttpClientResponse.fromWeb(request, new Response("", { status: 504 })),
219 );
220 }
221 return Effect.succeed(
222 HttpClientResponse.fromWeb(
223 request,
224 new Response(isChecksums ? checksums : archiveBytes),
225 ),
226 );
227 }),
228 runner: extractingRunner(fs, path),
229 validate: () => Effect.void,
230 }).pipe(Effect.forkScoped);
231 yield* Deferred.await(gatewayTimeoutServed);
232 yield* TestClock.adjust(Duration.seconds(2));
233 const paths = yield* Fiber.join(install);
234 assert.equal(yield* fs.readFileString(paths.sentinelPath), `${version}\n`);
235 assert.equal(requests.filter((url) => url.endsWith("/SHA256SUMS")).length, 2);
236 }),
237 );
238
239 it.effect("retries a release archive whose body fails after the host answered", () =>
240 Effect.gen(function* () {
241 const fs = yield* FileSystem.FileSystem;
242 const path = yield* Path.Path;
243 const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-pinned-retry-body-" });
244 const checksums = yield* validChecksums;
245 let archiveRequests = 0;
246 const archiveRequested = yield* Deferred.make<void>();
247 const install = yield* ensurePinnedRuntimeInstalled({
248 baseDir,
249 version,
250 fs,
251 path,
252 platform: "linux",
253 arch: "x64",
254 httpClient: HttpClient.make((request) => {
255 const isChecksums = request.url.endsWith("/SHA256SUMS");
256 const response = HttpClientResponse.fromWeb(
257 request,
258 new Response(isChecksums ? checksums : archiveBytes),
259 );
260 if (isChecksums || ++archiveRequests > 1) return Effect.succeed(response);
261 const failingBody = Stream.concat(
262 Stream.make(archiveBytes.slice(0, 4)),
263 Stream.fail(
264 new HttpClientError.HttpClientError({
265 reason: new HttpClientError.DecodeError({ request, response }),
266 }),
267 ),
268 );
269 Deferred.doneUnsafe(archiveRequested, Effect.void);
270 // TestClock follows the work a woken sleep starts, so one adjust reaches the retry wait.
271 return Effect.sleep("1 second").pipe(
272 Effect.as<HttpClientResponse.HttpClientResponse>(
273 Object.create(response, { stream: { value: failingBody } }),
274 ),
275 );
276 }),
277 runner: extractingRunner(fs, path),
278 validate: () => Effect.void,
279 }).pipe(Effect.forkScoped);
280 yield* Deferred.await(archiveRequested);
281 yield* TestClock.adjust(Duration.seconds(3));
282 const paths = yield* Fiber.join(install);
283 assert.equal(yield* fs.readFileString(paths.sentinelPath), `${version}\n`);
284 assert.equal(archiveRequests, 2);
285 }),
286 );
287
288 it.effect("fails at once when the release host cannot be reached", () =>
289 Effect.gen(function* () {
290 const fs = yield* FileSystem.FileSystem;
291 const path = yield* Path.Path;
292 const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-pinned-offline-" });
293 let requests = 0;
294 const error = yield* ensurePinnedRuntimeInstalled({
295 baseDir,
296 version,
297 fs,
298 path,
299 platform: "linux",
300 arch: "x64",
301 httpClient: HttpClient.make((request) => {
302 requests += 1;
303 return Effect.fail(
304 new HttpClientError.HttpClientError({
305 reason: new HttpClientError.TransportError({ request }),
306 }),
307 );
308 }),
309 runner: extractingRunner(fs, path),
310 validate: () => Effect.die("must not validate without a download"),
311 }).pipe(Effect.flip);
312 assert.instanceOf(error, PinnedRuntimeInstallError);
313 assert.equal(requests, 1);
314 }),
315 );
316
317 it.effect("refuses an archive whose checksum does not match the release", () =>
318 Effect.gen(function* () {
319 const fs = yield* FileSystem.FileSystem;
320 const path = yield* Path.Path;
321 const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-pinned-archive-bad-" });
322 const commands: string[] = [];
323 const error = yield* ensurePinnedRuntimeInstalled({
324 baseDir,
325 version,
326 fs,
327 path,
328 platform: "linux",
329 arch: "x64",
330 httpClient: releaseHttpClient(`${"0".repeat(64)} ${archiveName}\n`),
331 runner: extractingRunner(fs, path, commands),
332 validate: () => Effect.die("must not validate an unverified archive"),
333 }).pipe(Effect.flip);
334 assert.instanceOf(error, PinnedRuntimeInstallError);
335 assert.equal(error.step, "verifying the t3 release archive checksum");
336 assert.deepEqual(commands, []);
337 assert.deepEqual(yield* fs.readDirectory(path.join(baseDir, "runtime", "versions")), []);
338 }),
339 );
340
341 it.effect("validates a staging tree before atomically publishing it", () =>
342 Effect.gen(function* () {
343 const fs = yield* FileSystem.FileSystem;
344 const path = yield* Path.Path;
345 const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-pinned-runtime-test-" });
346 const finalPaths = pinnedRuntimePaths(path, baseDir, version, "linux");
347 let validatedDirectory = "";
348
349 const installed = yield* ensurePinnedRuntimeInstalled({
350 baseDir,
351 version,
352 fs,
353 path,
354 platform: "linux",
355 arch: "x64",
356 httpClient: releaseHttpClient(yield* validChecksums),
357 runner: extractingRunner(fs, path),
358 validate: (staging) =>
359 Effect.gen(function* () {
360 validatedDirectory = staging.versionDir;
361 assert.isFalse(yield* fs.exists(finalPaths.versionDir));
362 assert.isTrue(yield* fs.exists(staging.entryPath));
363 }).pipe(Effect.orDie),
364 });
365
366 assert.notEqual(validatedDirectory, finalPaths.versionDir);
367 assert.deepEqual(installed, finalPaths);
368 assert.isTrue(yield* fs.exists(finalPaths.entryPath));
369 assert.equal(yield* fs.readFileString(finalPaths.sentinelPath), `${version}\n`);
370 }),
371 );
372
373 it.effect("removes staging and leaves no final runtime when validation fails", () =>
374 Effect.gen(function* () {
375 const fs = yield* FileSystem.FileSystem;
376 const path = yield* Path.Path;
377 const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-pinned-runtime-test-" });
378 const finalPaths = pinnedRuntimePaths(path, baseDir, version, "linux");
379
380 yield* ensurePinnedRuntimeInstalled({
381 baseDir,
382 version,
383 fs,
384 path,
385 platform: "linux",
386 arch: "x64",
387 httpClient: releaseHttpClient(yield* validChecksums),
388 runner: extractingRunner(fs, path),
389 validate: () =>
390 Effect.fail(new PinnedRuntimeInstallError({ step: "validating the staged runtime" })),
391 }).pipe(Effect.flip);
392
393 assert.isFalse(yield* fs.exists(finalPaths.versionDir));
394 assert.deepEqual(
395 (yield* fs.readDirectory(path.dirname(finalPaths.versionDir))).filter((entry) =>
396 entry.startsWith(".staging-"),
397 ),
398 [],
399 );
400 }),
401 );
402
403 it.effect("replaces an incomplete pinned runtime", () =>
404 Effect.gen(function* () {
405 const fs = yield* FileSystem.FileSystem;
406 const path = yield* Path.Path;
407 const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-pinned-runtime-repair-" });
408 const finalPaths = pinnedRuntimePaths(path, baseDir, version, "linux");
409 yield* fs.makeDirectory(finalPaths.versionDir, { recursive: true });
410 yield* fs.writeFileString(path.join(finalPaths.versionDir, "partial"), "incomplete\n");
411
412 yield* ensurePinnedRuntimeInstalled({
413 baseDir,
414 version,
415 fs,
416 path,
417 platform: "linux",
418 arch: "x64",
419 httpClient: releaseHttpClient(yield* validChecksums),
420 runner: extractingRunner(fs, path),
421 validate: () => Effect.void,
422 });
423
424 assert.isFalse(yield* fs.exists(path.join(finalPaths.versionDir, "partial")));
425 assert.isTrue(yield* fs.exists(finalPaths.entryPath));
426 }),
427 );
428
429 it.effect("reuses a cached runtime under a mirror only while the mirror lists it", () =>
430 Effect.gen(function* () {
431 const fs = yield* FileSystem.FileSystem;
432 const path = yield* Path.Path;
433 const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-pinned-runtime-mirror-" });
434 const cached = pinnedRuntimePaths(path, baseDir, version, "linux");
435 yield* fs.makeDirectory(path.dirname(cached.entryPath), { recursive: true });
436 yield* fs.writeFileString(cached.entryPath, "#!/bin/sh\n");
437 yield* fs.writeFileString(cached.sentinelPath, `${version}\n`);
438 const install = (checksums: string, requests: string[]) =>
439 ensurePinnedRuntimeInstalled({
440 baseDir,
441 version,
442 fs,
443 path,
444 platform: "linux",
445 arch: "x64",
446 httpClient: releaseHttpClient(checksums, requests),
447 // A signed mirror URL keeps its query on every file it serves.
448 releaseBaseUrl: "https://updates.example.com/t3code/?token=abc",
449 requirePublishedRelease: true,
450 runner: extractingRunner(fs, path),
451 validate: () => Effect.void,
452 });
453
454 const withdrawn = yield* install("", []).pipe(Effect.flip);
455 assert.include(withdrawn.message, `finding ${archiveName} in the t3 release checksums`);
456 const requests: string[] = [];
457 assert.equal((yield* install(yield* validChecksums, requests)).entryPath, cached.entryPath);
458 // Listed, so the cached runtime is used without downloading it again.
459 assert.deepEqual(requests, [
460 `https://updates.example.com/t3code/v${version}/SHA256SUMS?token=abc`,
461 ]);
462 }),
463 );
464
465 it.effect("preserves a completed runtime when validation fails", () =>
466 Effect.gen(function* () {
467 const fs = yield* FileSystem.FileSystem;
468 const path = yield* Path.Path;
469 const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-pinned-runtime-repair-" });
470 const finalPaths = pinnedRuntimePaths(path, baseDir, version, "linux");
471 yield* fs.makeDirectory(path.dirname(finalPaths.entryPath), { recursive: true });
472 yield* fs.writeFileString(finalPaths.entryPath, "broken\n");
473 yield* fs.writeFileString(finalPaths.sentinelPath, `${version}\n`);
474
475 let validations = 0;
476 const requests: string[] = [];
477 yield* ensurePinnedRuntimeInstalled({
478 baseDir,
479 version,
480 fs,
481 path,
482 platform: "linux",
483 arch: "x64",
484 httpClient: releaseHttpClient(yield* validChecksums, requests),
485 runner: extractingRunner(fs, path),
486 validate: (paths) =>
487 Effect.gen(function* () {
488 validations += 1;
489 const source = yield* fs.readFileString(paths.entryPath).pipe(Effect.orDie);
490 if (source === "broken\n") {
491 return yield* new PinnedRuntimeInstallError({ step: "validating the runtime" });
492 }
493 }),
494 }).pipe(Effect.flip);
495
496 assert.equal(validations, 1);
497 assert.deepEqual(requests, []);
498 assert.equal(yield* fs.readFileString(finalPaths.entryPath), "broken\n");
499 }),
500 );
501});
502
503it("isVersionExecutable accepts only <version>/t3, never another drive or program", () => {
504 const win32 = NodePath.win32 as unknown as Path.Path;
505 assert.isTrue(isVersionExecutable(win32, "C:\\t3\\versions", "C:\\t3\\versions\\1.0.0\\t3.exe"));
506 assert.isFalse(isVersionExecutable(win32, "C:\\t3\\versions", "D:\\t3.exe"));
507 assert.isFalse(
508 isVersionExecutable(win32, "C:\\t3\\versions", "C:\\t3\\versions\\1.0.0\\node.exe"),
509 );
510});
511
512it.layer(NodeServices.layer)("prunePinnedRuntimes", (it) => {
513 const lsofRunner = (stdout: string) =>
514 ProcessRunner.ProcessRunner.of({
515 run: () =>
516 Effect.succeed({
517 stdout,
518 stderr: "",
519 code: ChildProcessSpawner.ExitCode(0),
520 timedOut: false,
521 stdoutTruncated: false,
522 stderrTruncated: false,
523 stdoutInvalidUtf8: false,
524 stderrInvalidUtf8: false,
525 }),
526 });
527
528 const setup = Effect.fn(function* (versions: ReadonlyArray<string>) {
529 const fs = yield* FileSystem.FileSystem;
530 const path = yield* Path.Path;
531 const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-pinned-prune-" });
532 const versionsDir = path.join(baseDir, "runtime", "versions");
533 const now = Duration.toMillis(Duration.days(1));
534 yield* TestClock.setTime(now);
535 const install = Effect.fn(function* (
536 version: string,
537 completed: "long ago" | "just now" | "never" = "long ago",
538 ) {
539 const directory = path.join(versionsDir, version);
540 yield* fs.makeDirectory(directory, { recursive: true });
541 yield* fs.writeFileString(path.join(directory, "t3"), "");
542 if (completed === "never") return;
543 const sentinel = path.join(directory, ".install-complete");
544 yield* fs.writeFileString(sentinel, `${version}\n`);
545 const at = completed === "just now" ? now : 0;
546 yield* fs.utimes(sentinel, at, at);
547 });
548 for (const version of versions) yield* install(version);
549 return { baseDir, versionsDir, install };
550 });
551
552 it.effect("removes only completed runtimes nothing references", () =>
553 Effect.gen(function* () {
554 const fs = yield* FileSystem.FileSystem;
555 const path = yield* Path.Path;
556 const { baseDir, versionsDir, install } = yield* setup([
557 "1.0.0",
558 "1.1.0",
559 "1.2.0",
560 "1.2.5",
561 "1.3.0",
562 "1.3.5",
563 "1.4.0",
564 "1.5.0",
565 "2.0.0",
566 "2.1.0",
567 ]);
568 // A downgrade another process installed but has not pointed anything at yet.
569 yield* install("0.5.0", "just now");
570 yield* install("1.0.1", "never");
571 yield* install(".staging-abc", "never");
572 yield* install(".pruned-0.8.0");
573 yield* install("other", "never");
574 yield* fs.symlink(path.join(versionsDir, "other"), path.join(versionsDir, "0.9.0"));
575 const binDir = path.join(baseDir, "bin");
576 const customBinDir = path.join(baseDir, "custom-bin");
577 yield* fs.makeDirectory(binDir);
578 yield* fs.makeDirectory(customBinDir);
579 // Chained: the launcher reaches its runtime through a second link.
580 yield* fs.symlink(path.join(versionsDir, "1.2.0", "t3"), path.join(baseDir, "alias-t3"));
581 yield* fs.symlink(path.join(baseDir, "alias-t3"), path.join(binDir, "t3"));
582 yield* fs.symlink(path.join(versionsDir, "1.2.5", "t3"), path.join(customBinDir, "t3"));
583 yield* fs.writeFileString(
584 path.join(baseDir, "runtime", "launchers"),
585 `${path.join(customBinDir, "t3")}\n`,
586 );
587 yield* fs.writeFileString(
588 path.join(baseDir, "runtime", "service-state.json"),
589 JSON.stringify({
590 protocol: SERVICE_LAUNCHER_PROTOCOL,
591 activeVersion: "1.4.0",
592 update: { id: "u", fromVersion: "1.4.0", targetVersion: "1.5.0", status: "rolled-back" },
593 }),
594 );
595
596 const removed = yield* prunePinnedRuntimes({
597 baseDir,
598 keep: ["2.0.0"],
599 references: [`ExecStart=${path.join(versionsDir, "1.3.5", "t3")} __service-launcher`],
600 }).pipe(
601 Effect.provideService(
602 ProcessRunner.ProcessRunner,
603 lsofRunner(`p42\nftxt\nn${path.join(versionsDir, "1.3.0", "t3")}\n`),
604 ),
605 Effect.provideService(HostProcess.Platform, "darwin"),
606 Effect.provideService(HostProcess.Environment, { T3CODE_INSTALL_BIN_DIR: binDir }),
607 );
608
609 // 1.2.0 is the default launcher, 1.2.5 a recorded one, 1.3.0 running, 1.3.5 in the unit,
610 // 1.4.0 active, 1.5.0 the rolled-back target, 2.0.0 the caller's, 0.5.0 just installed.
611 // Being newer than the caller's version does not keep 2.1.0.
612 assert.deepEqual(removed.toSorted(), ["0.8.0", "1.0.0", "1.1.0", "2.1.0"]);
613 // The launcher found on PATH is remembered for a service that cannot see it.
614 assert.equal(
615 yield* fs.readFileString(path.join(baseDir, "runtime", "launchers")),
616 `${path.join(customBinDir, "t3")}\n\n${path.join(binDir, "t3")}\n`,
617 );
618 assert.deepEqual((yield* fs.readDirectory(versionsDir)).toSorted(), [
619 ".staging-abc",
620 "0.5.0",
621 "0.9.0",
622 "1.0.1",
623 "1.2.0",
624 "1.2.5",
625 "1.3.0",
626 "1.3.5",
627 "1.4.0",
628 "1.5.0",
629 "2.0.0",
630 "other",
631 ]);
632 }),
633 );
634
635 it.effect.each([
636 { name: "its directory is on PATH", onPath: true, recorded: false, linkedHome: false },
637 {
638 name: "an earlier prune recorded its entry as a launcher",
639 onPath: false,
640 recorded: true,
641 linkedHome: false,
642 },
643 {
644 name: "its real directory in a symlinked home is on PATH",
645 onPath: true,
646 recorded: false,
647 linkedHome: true,
648 },
649 ])("removes a runtime only its own entry names when $name", ({ onPath, recorded, linkedHome }) =>
650 Effect.gen(function* () {
651 const fs = yield* FileSystem.FileSystem;
652 const path = yield* Path.Path;
653 const { baseDir, versionsDir } = yield* setup(["1.0.0", "1.1.0"]);
654 const binDir = path.join(baseDir, "bin");
655 yield* fs.makeDirectory(binDir);
656 yield* fs.symlink(path.join(versionsDir, "1.1.0", "t3"), path.join(binDir, "t3"));
657 const ownDir = path.join(versionsDir, "1.0.0");
658 if (recorded) {
659 yield* fs.writeFileString(
660 path.join(baseDir, "runtime", "launchers"),
661 `${path.join(ownDir, "t3")}\n`,
662 );
663 }
664
665 const home = linkedHome
666 ? path.join(yield* fs.makeTempDirectoryScoped({ prefix: "t3-pinned-link-" }), "home")
667 : baseDir;
668 if (linkedHome) yield* fs.symlink(baseDir, home);
669
670 // The launch agent puts the running runtime's own directory on PATH.
671 const removed = yield* prunePinnedRuntimes({ baseDir: home, keep: ["2.0.0"] }).pipe(
672 Effect.provideService(
673 ProcessRunner.ProcessRunner,
674 lsofRunner("p1\nftxt\nn/sbin/launchd\n"),
675 ),
676 Effect.provideService(HostProcess.Platform, "darwin"),
677 Effect.provideService(HostProcess.Environment, {
678 T3CODE_INSTALL_BIN_DIR: binDir,
679 PATH: onPath ? `${ownDir}:/usr/bin:/bin` : "/usr/bin:/bin",
680 }),
681 );
682 assert.deepEqual(removed, ["1.0.0"]);
683 }),
684 );
685
686 it.effect.each([
687 { name: "a launcher inside the versions directory", via: "bin" as const },
688 { name: "a PATH directory linked to its version directory", via: "linked dir" as const },
689 {
690 name: "a PATH directory in the versions directory linked to its version directory",
691 via: "linked versions dir" as const,
692 },
693 ])("keeps a runtime $name names", ({ via }) =>
694 Effect.gen(function* () {
695 const fs = yield* FileSystem.FileSystem;
696 const path = yield* Path.Path;
697 const { baseDir, versionsDir } = yield* setup(["1.0.0", "1.1.0"]);
698 let pathDirs = "/usr/bin:/bin";
699 if (via === "bin") {
700 // The installer accepts any bin directory, including one under the versions directory.
701 const binDir = path.join(versionsDir, "bin");
702 yield* fs.makeDirectory(binDir);
703 yield* fs.symlink(path.join(versionsDir, "1.0.0", "t3"), path.join(binDir, "t3"));
704 yield* fs.writeFileString(
705 path.join(baseDir, "runtime", "launchers"),
706 `${path.join(binDir, "t3")}\n`,
707 );
708 } else {
709 const linkedDir = path.join(via === "linked dir" ? baseDir : versionsDir, "current");
710 yield* fs.symlink(path.join(versionsDir, "1.0.0"), linkedDir);
711 pathDirs = `${linkedDir}:${pathDirs}`;
712 }
713
714 const removed = yield* prunePinnedRuntimes({ baseDir, keep: ["2.0.0"] }).pipe(
715 Effect.provideService(
716 ProcessRunner.ProcessRunner,
717 lsofRunner("p1\nftxt\nn/sbin/launchd\n"),
718 ),
719 Effect.provideService(HostProcess.Platform, "darwin"),
720 Effect.provideService(HostProcess.Environment, { PATH: pathDirs }),
721 );
722 assert.deepEqual(removed, ["1.1.0"]);
723 }),
724 );
725
726 it.effect.each([
727 // An interrupted append cut this record short.
728 { name: "an unterminated launcher record", recorded: "/opt/t", cut: "" },
729 // Another process's append is cut short after prune reads the records.
730 { name: "a record cut short after the read", recorded: "/opt/t3\n", cut: "/opt/t" },
731 ])("remembers a launcher found on PATH after $name", ({ recorded, cut }) =>
732 Effect.gen(function* () {
733 const fs = yield* FileSystem.FileSystem;
734 const path = yield* Path.Path;
735 const { baseDir, versionsDir } = yield* setup(["1.0.0", "1.1.0"]);
736 const binDir = path.join(baseDir, "custom-bin");
737 yield* fs.makeDirectory(binDir);
738 yield* fs.symlink(path.join(versionsDir, "1.0.0", "t3"), path.join(binDir, "t3"));
739 const launchersFile = path.join(baseDir, "runtime", "launchers");
740 yield* fs.writeFileString(launchersFile, recorded);
741 const prune = (environment: Record<string, string>) =>
742 prunePinnedRuntimes({ baseDir, keep: [] }).pipe(
743 Effect.provideService(ProcessRunner.ProcessRunner, lsofRunner("p1\nftxt\nn/bin/sh\n")),
744 Effect.provideService(HostProcess.Platform, "darwin"),
745 Effect.provideService(HostProcess.Environment, environment),
746 Effect.provideService(HostProcess.HomeDirectory, baseDir),
747 );
748
749 yield* prune({ PATH: binDir }).pipe(
750 Effect.provideService(FileSystem.FileSystem, {
751 ...fs,
752 readFileString: (file, encoding) =>
753 fs
754 .readFileString(file, encoding)
755 .pipe(
756 Effect.tap(() =>
757 file === launchersFile
758 ? fs.writeFileString(file, cut, { flag: "a" })
759 : Effect.void,
760 ),
761 ),
762 }),
763 );
764 // A service that cannot see the interactive PATH.
765 const removed = yield* prune({});
766
767 assert.deepEqual(removed, []);
768 assert.isTrue(yield* fs.exists(path.join(versionsDir, "1.0.0", "t3")));
769 }),
770 );
771
772 it.effect("matches a Windows shim whatever its casing", () =>
773 Effect.gen(function* () {
774 const fs = yield* FileSystem.FileSystem;
775 const path = yield* Path.Path;
776 const { baseDir, versionsDir } = yield* setup(["1.0.0", "1.1.0"]);
777 const shim = path.join(baseDir, "t3.cmd");
778 yield* fs.writeFileString(
779 shim,
780 `@echo off\r\n"${path.join(versionsDir, "1.0.0", "t3.exe").toUpperCase()}" %*`,
781 );
782 yield* fs.writeFileString(path.join(baseDir, "runtime", "launchers"), `${shim}\n`);
783
784 const removed = yield* prunePinnedRuntimes({ baseDir, keep: ["2.0.0"] }).pipe(
785 Effect.provideService(
786 ProcessRunner.ProcessRunner,
787 lsofRunner("C:\\Windows\\explorer.exe\n"),
788 ),
789 Effect.provideService(HostProcess.Platform, "win32"),
790 Effect.provideService(HostProcess.Environment, {}),
791 );
792 assert.deepEqual(removed, ["1.1.0"]);
793 }),
794 );
795
796 it.effect.each([
797 { name: "the process list is unavailable", state: undefined, lsof: "" },
798 { name: "the service state is unparseable", state: "{", lsof: "p1\nftxt\nn/sbin/launchd\n" },
799 {
800 name: "the service state cannot be read",
801 state: null,
802 lsof: "p1\nftxt\nn/sbin/launchd\n",
803 },
804 ])("keeps everything when $name", ({ state, lsof }) =>
805 Effect.gen(function* () {
806 const fs = yield* FileSystem.FileSystem;
807 const path = yield* Path.Path;
808 const { baseDir, versionsDir } = yield* setup(["1.0.0"]);
809 const statePath = path.join(baseDir, "runtime", "service-state.json");
810 if (state === null) yield* fs.makeDirectory(statePath);
811 else if (state !== undefined) yield* fs.writeFileString(statePath, state);
812
813 const removed = yield* prunePinnedRuntimes({ baseDir, keep: ["2.0.0"] }).pipe(
814 Effect.provideService(ProcessRunner.ProcessRunner, lsofRunner(lsof)),
815 Effect.provideService(HostProcess.Platform, "darwin"),
816 Effect.provideService(HostProcess.Environment, { T3CODE_INSTALL_BIN_DIR: baseDir }),
817 );
818 assert.deepEqual(removed, []);
819 assert.deepEqual(yield* fs.readDirectory(versionsDir), ["1.0.0"]);
820 }),
821 );
822
823 it.effect.skipIf(HostProcess.Platform.defaultValue() !== "linux")(
824 "keeps a runtime a process runs through a symlink",
825 () =>
826 Effect.gen(function* () {
827 const fs = yield* FileSystem.FileSystem;
828 const path = yield* Path.Path;
829 const { baseDir, versionsDir } = yield* setup(["1.0.0", "1.1.0"]);
830 const entry = path.join(versionsDir, "1.0.0", "t3");
831 // Multicall coreutils (Ubuntu's uutils) refuse to run renamed, so copy Node instead.
832 yield* fs.copyFile(process.execPath, entry);
833 yield* fs.chmod(entry, 0o755);
834 const link = path.join(baseDir, "t3");
835 yield* fs.symlink(entry, link);
836 // Its argv names only the link, as with `t3 serve` run from PATH. Removing the link after
837 // spawning leaves the process's executable as the only reference.
838 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
839 yield* spawner.spawn(ChildProcess.make(link, ["-e", "setTimeout(() => {}, 30_000)"]));
840 yield* fs.remove(link);
841
842 const removed = yield* prunePinnedRuntimes({ baseDir, keep: ["2.0.0"] }).pipe(
843 Effect.provideService(HostProcess.Platform, "linux"),
844 Effect.provideService(HostProcess.Environment, { T3CODE_INSTALL_BIN_DIR: baseDir }),
845 Effect.provide(ProcessRunner.layer),
846 );
847 assert.deepEqual(removed, ["1.1.0"]);
848 }),
849 );
850});