Open History to see the last commit
1import * as Cause from "effect/Cause";
2import * as Clock from "effect/Clock";
3import * as Duration from "effect/Duration";
4import * as Effect from "effect/Effect";
5import * as Hex from "effect/encoding/Hex";
6import * as FileSystem from "effect/FileSystem";
7import * as Path from "effect/Path";
8import * as Schema from "effect/Schema";
9import * as Option from "effect/Option";
10import * as Schedule from "effect/Schedule";
11import * as Stream from "effect/Stream";
12import * as Semaphore from "effect/Semaphore";
13import { HttpClient, HttpClientError, HttpClientRequest, HttpClientResponse } from "effect/http";
14
15import {
16 CLI_RELEASE_CHECKSUMS_FILE,
17 cliArchiveFileName,
18 cliArchivePlatformKey,
19 cliArchiveTarCommand,
20 cliReleaseAssetUrl,
21 type CliArchivePlatformKey,
22 parseChecksums,
23} from "@t3tools/shared/cliRelease";
24
25import * as HostProcess from "@t3tools/shared/HostProcess";
26
27import * as ProcessRunner from "../processRunner.ts";
28import { isExactServiceVersion, parseServiceState, SERVICE_STATE_FILE } from "./serviceProtocol.ts";
29
30const PINNED_RUNTIME_DIR = "runtime";
31const PINNED_RUNTIME_INSTALL_TIMEOUT = Duration.minutes(10);
32const PINNED_RUNTIME_DOWNLOAD_STALL_TIMEOUT = Duration.minutes(1);
33const PINNED_RUNTIME_ARCHIVE_FILE = "t3-runtime-archive";
34// Separate layers share one install lock across this process.
35const pinnedRuntimeInstallLock = Semaphore.makeUnsafe(1);
36
37export interface PinnedRuntimePaths {
38 readonly versionDir: string;
39 readonly entryPath: string;
40 readonly sentinelPath: string;
41}
42
43export function pinnedRuntimeCommand(paths: PinnedRuntimePaths): {
44 readonly command: string;
45 readonly args: ReadonlyArray<string>;
46} {
47 return { command: paths.entryPath, args: [] };
48}
49
50export function pinnedRuntimeVersionsDir(path: Path.Path, baseDir: string): string {
51 return path.join(baseDir, PINNED_RUNTIME_DIR, "versions");
52}
53
54export function pinnedRuntimePaths(
55 path: Path.Path,
56 baseDir: string,
57 version: string,
58 platform: NodeJS.Platform,
59): PinnedRuntimePaths {
60 const versionDir = path.join(pinnedRuntimeVersionsDir(path, baseDir), version);
61 return {
62 versionDir,
63 entryPath: path.join(versionDir, platform === "win32" ? "t3.exe" : "t3"),
64 sentinelPath: path.join(versionDir, ".install-complete"),
65 };
66}
67
68export class PinnedRuntimeInstallError extends Schema.TaggedError<PinnedRuntimeInstallError>()(
69 "PinnedRuntimeInstallError",
70 {
71 step: Schema.String,
72 exitCode: Schema.optional(Schema.Number),
73 stdoutLength: Schema.optional(Schema.Number),
74 stderrLength: Schema.optional(Schema.Number),
75 httpStatus: Schema.optional(Schema.Number),
76 downloadHost: Schema.optional(Schema.String),
77 cause: Schema.optional(Schema.Defect()),
78 },
79) {
80 override get message(): string {
81 return this.exitCode === undefined
82 ? `Pinned runtime install failed while ${this.step}.`
83 : `Pinned runtime install failed while ${this.step} (exit code ${this.exitCode}).`;
84 }
85}
86
87export class PinnedRuntimePreflightBlockedError extends Schema.TaggedError<PinnedRuntimePreflightBlockedError>()(
88 "PinnedRuntimePreflightBlockedError",
89 {
90 version: Schema.String,
91 reason: Schema.String,
92 },
93) {
94 override get message(): string {
95 return this.reason;
96 }
97}
98
99export type PinnedRuntimeProgress =
100 | { readonly stage: "download"; readonly received: number; readonly total: number | undefined }
101 | { readonly stage: "verify" | "extract" | "validate" | "cached" };
102
103/** Tar writes the executable before native packages; only a validated install gets a sentinel. */
104
105interface PinnedRuntimeInstallInput {
106 readonly baseDir: string;
107 readonly version: string;
108 readonly fs: FileSystem.FileSystem;
109 readonly path: Path.Path;
110 readonly runner: ProcessRunner.ProcessRunner["Service"];
111 readonly validate: (
112 paths: PinnedRuntimePaths,
113 ) => Effect.Effect<void, PinnedRuntimeInstallError | PinnedRuntimePreflightBlockedError>;
114 readonly platform: NodeJS.Platform;
115 readonly arch: string;
116 readonly httpClient: HttpClient.HttpClient;
117 readonly releaseBaseUrl?: string | undefined;
118 /** Reuse a cached runtime only while the release source still lists it, as a policy mirror does. */
119 readonly requirePublishedRelease?: boolean;
120 readonly onProgress?: (progress: PinnedRuntimeProgress) => void;
121}
122
123// GitHub release downloads intermittently answer 5xx during incidents; a retry usually lands.
124const DOWNLOAD_RETRY = { times: 3, schedule: Schedule.exponential("2 seconds") } as const;
125
126const isTransientDownloadFailure = (
127 error: HttpClientError.HttpClientError | Cause.TimeoutError,
128): boolean => {
129 // No answer or a stall means the host cannot reach the release source; retrying only delays that.
130 if (error._tag === "TimeoutError") return false;
131 // Body bytes are read raw, so a DecodeError is a body that failed after the host answered.
132 if (error.reason._tag === "DecodeError") return true;
133 const status = error.response?.status;
134 return status !== undefined && (status >= 500 || status === 429 || status === 408);
135};
136
137const fetchReleaseAsset = Effect.fn("cloud.pinned_runtime.fetch_release_asset")(function* (
138 httpClient: HttpClient.HttpClient,
139 url: string,
140 step: string,
141 onProgress?: (progress: PinnedRuntimeProgress) => void,
142) {
143 // Bound stalls while holding the install lock, but allow transfers that keep
144 // receiving bytes to finish regardless of their total duration.
145 return yield* httpClient.execute(HttpClientRequest.get(url)).pipe(
146 Effect.flatMap(HttpClientResponse.filterStatusOk),
147 Effect.timeout(PINNED_RUNTIME_DOWNLOAD_STALL_TIMEOUT),
148 Effect.flatMap(
149 Effect.fn(function* (response) {
150 const length = Number(response.headers["content-length"]);
151 const total = Number.isFinite(length) && length > 0 ? length : undefined;
152 let received = 0;
153 onProgress?.({ stage: "download", received, total });
154 const chunks = yield* response.stream.pipe(
155 Stream.timeoutOrElse({
156 duration: PINNED_RUNTIME_DOWNLOAD_STALL_TIMEOUT,
157 orElse: () => Stream.fail(new Cause.TimeoutError()),
158 }),
159 Stream.tap((chunk) =>
160 Effect.sync(() => {
161 received += chunk.byteLength;
162 onProgress?.({ stage: "download", received, total });
163 }),
164 ),
165 Stream.runCollect,
166 );
167 if (total === undefined && received > 0) {
168 onProgress?.({ stage: "download", received, total: received });
169 }
170 const bytes = new Uint8Array(received);
171 let offset = 0;
172 for (const chunk of chunks) {
173 bytes.set(chunk, offset);
174 offset += chunk.byteLength;
175 }
176 return bytes;
177 }),
178 ),
179 Effect.retry({ ...DOWNLOAD_RETRY, while: isTransientDownloadFailure }),
180 Effect.mapError(
181 (cause) =>
182 new PinnedRuntimeInstallError({
183 step: cause._tag === "TimeoutError" ? `${step} (timed out)` : step,
184 httpStatus:
185 HttpClientError.isHttpClientError(cause) && cause.reason._tag === "StatusCodeError"
186 ? cause.reason.response.status
187 : undefined,
188 downloadHost: URL.parse(
189 HttpClientError.isHttpClientError(cause) ? cause.request.url : url,
190 )?.host,
191 cause,
192 }),
193 ),
194 );
195});
196
197const installFromArchive = Effect.fn("cloud.pinned_runtime.install_archive")(function* (
198 input: PinnedRuntimeInstallInput,
199 stagingDir: string,
200) {
201 const { fs, path } = input;
202 const platformKey = cliArchivePlatformKey(input.platform, input.arch);
203 if (platformKey === undefined) {
204 return yield* new PinnedRuntimeInstallError({
205 step: `selecting a t3 release archive for ${input.platform}-${input.arch}`,
206 });
207 }
208 const httpClient = input.httpClient;
209 input.onProgress?.({ stage: "download", received: 0, total: undefined });
210 const { fileName, expected } = yield* publishedArchive(input, platformKey);
211 const archive = yield* fetchReleaseAsset(
212 httpClient,
213 cliReleaseAssetUrl(input.version, fileName, input.releaseBaseUrl),
214 "downloading the t3 release archive",
215 input.onProgress,
216 );
217 input.onProgress?.({ stage: "verify" });
218 const digest = yield* Effect.tryPromise({
219 try: () => crypto.subtle.digest("SHA-256", archive),
220 catch: (cause) =>
221 new PinnedRuntimeInstallError({ step: "verifying the t3 release archive", cause }),
222 });
223 if (Hex.encode(new Uint8Array(digest)) !== expected) {
224 return yield* new PinnedRuntimeInstallError({
225 step: "verifying the t3 release archive checksum",
226 });
227 }
228
229 const archivePath = path.join(stagingDir, PINNED_RUNTIME_ARCHIVE_FILE);
230 yield* fs
231 .writeFile(archivePath, archive)
232 .pipe(
233 Effect.mapError(
234 (cause) => new PinnedRuntimeInstallError({ step: "writing the t3 release archive", cause }),
235 ),
236 );
237 input.onProgress?.({ stage: "extract" });
238 const extractStep = "extracting the t3 release archive";
239 // Strip the archive's stem directory so the executable lands at <versionDir>/t3.
240 yield* input.runner
241 .run({
242 command: cliArchiveTarCommand(input.platform, process.env),
243 args: ["-xf", archivePath, "-C", stagingDir, "--strip-components=1"],
244 timeout: PINNED_RUNTIME_INSTALL_TIMEOUT,
245 })
246 .pipe(
247 Effect.mapError((cause) => new PinnedRuntimeInstallError({ step: extractStep, cause })),
248 Effect.filterOrFail(
249 (result) => result.code === 0,
250 (result) =>
251 new PinnedRuntimeInstallError({
252 step: extractStep,
253 exitCode: Number(result.code),
254 stdoutLength: result.stdout.length,
255 stderrLength: result.stderr.length,
256 }),
257 ),
258 );
259 yield* fs.remove(archivePath, { force: true }).pipe(Effect.ignore);
260});
261
262/** The release's archive for this machine and its checksum; fails when the release lists none. */
263const publishedArchive = Effect.fn("cloud.pinned_runtime.published_archive")(function* (
264 input: PinnedRuntimeInstallInput,
265 platformKey: CliArchivePlatformKey,
266) {
267 const fileName = cliArchiveFileName(input.version, platformKey);
268 const checksums = parseChecksums(
269 new TextDecoder().decode(
270 yield* fetchReleaseAsset(
271 input.httpClient,
272 cliReleaseAssetUrl(input.version, CLI_RELEASE_CHECKSUMS_FILE, input.releaseBaseUrl),
273 "downloading the t3 release checksums",
274 ),
275 ),
276 );
277 const expected = checksums.get(fileName);
278 if (expected === undefined) {
279 return yield* new PinnedRuntimeInstallError({
280 step: `finding ${fileName} in the t3 release checksums`,
281 });
282 }
283 return { fileName, expected };
284});
285
286const isPinnedRuntimeInstallError = Schema.is(PinnedRuntimeInstallError);
287
288/** Whether an install failed because the release source does not publish the version for this machine. */
289export const isUnpublishedRelease = (error: unknown): error is PinnedRuntimeInstallError =>
290 isPinnedRuntimeInstallError(error) &&
291 (error.step.startsWith("finding ") ||
292 (error.step.startsWith("downloading the t3 release") &&
293 HttpClientError.isHttpClientError(error.cause) &&
294 error.cause.response?.status === 404));
295
296const installPinnedRuntime = Effect.fn("cloud.pinned_runtime.ensure_installed")(function* (
297 input: PinnedRuntimeInstallInput,
298) {
299 const { fs } = input;
300 const paths = pinnedRuntimePaths(input.path, input.baseDir, input.version, input.platform);
301 const [versionDirExists, entryExists, sentinel] = yield* Effect.all([
302 fs.exists(paths.versionDir),
303 fs.exists(paths.entryPath),
304 fs.readFileString(paths.sentinelPath).pipe(Effect.option),
305 ]).pipe(
306 Effect.mapError(
307 (cause) => new PinnedRuntimeInstallError({ step: "checking the pinned runtime", cause }),
308 ),
309 );
310 const alreadyPinned =
311 entryExists && Option.isSome(sentinel) && sentinel.value.trim() === input.version;
312 if (alreadyPinned) {
313 if (input.requirePublishedRelease === true) {
314 const platformKey = cliArchivePlatformKey(input.platform, input.arch);
315 if (platformKey === undefined) {
316 return yield* new PinnedRuntimeInstallError({
317 step: `selecting a t3 release archive for ${input.platform}-${input.arch}`,
318 });
319 }
320 yield* publishedArchive(input, platformKey);
321 }
322 input.onProgress?.({ stage: "cached" });
323 yield* input.validate(paths);
324 return paths;
325 }
326 if (versionDirExists) {
327 yield* fs.remove(paths.versionDir, { recursive: true, force: true }).pipe(
328 Effect.mapError(
329 (cause) =>
330 new PinnedRuntimeInstallError({
331 step: "removing an incomplete pinned runtime",
332 cause,
333 }),
334 ),
335 );
336 }
337
338 const versionsDir = input.path.dirname(paths.versionDir);
339 yield* fs.makeDirectory(versionsDir, { recursive: true }).pipe(
340 Effect.mapError(
341 (cause) =>
342 new PinnedRuntimeInstallError({
343 step: "preparing the pinned runtime directory",
344 cause,
345 }),
346 ),
347 );
348 const stagingDir = yield* fs
349 .makeTempDirectory({
350 directory: versionsDir,
351 prefix: ".staging-",
352 })
353 .pipe(
354 Effect.mapError(
355 (cause) =>
356 new PinnedRuntimeInstallError({
357 step: "preparing the pinned runtime directory",
358 cause,
359 }),
360 ),
361 );
362 const stagingPaths: PinnedRuntimePaths = {
363 versionDir: stagingDir,
364 entryPath: input.path.join(stagingDir, input.path.relative(paths.versionDir, paths.entryPath)),
365 sentinelPath: input.path.join(stagingDir, ".install-complete"),
366 };
367
368 return yield* Effect.gen(function* () {
369 yield* installFromArchive(input, stagingDir);
370
371 input.onProgress?.({ stage: "validate" });
372 yield* input.validate(stagingPaths);
373 yield* fs
374 .writeFileString(stagingPaths.sentinelPath, `${input.version}\n`)
375 .pipe(
376 Effect.mapError(
377 (cause) =>
378 new PinnedRuntimeInstallError({ step: "recording the completed install", cause }),
379 ),
380 );
381 const published = yield* fs.rename(stagingDir, paths.versionDir).pipe(
382 Effect.as(true),
383 Effect.catch((cause) =>
384 Effect.all([
385 fs.exists(paths.entryPath),
386 fs.readFileString(paths.sentinelPath).pipe(Effect.option),
387 ]).pipe(
388 Effect.mapError(
389 (checkCause) =>
390 new PinnedRuntimeInstallError({
391 step: "checking a concurrently published pinned runtime",
392 cause: checkCause,
393 }),
394 ),
395 Effect.flatMap(([publishedEntryExists, publishedSentinel]) =>
396 publishedEntryExists &&
397 Option.isSome(publishedSentinel) &&
398 publishedSentinel.value.trim() === input.version
399 ? Effect.succeed(false)
400 : Effect.fail(
401 new PinnedRuntimeInstallError({
402 step: "publishing the pinned runtime",
403 cause,
404 }),
405 ),
406 ),
407 ),
408 ),
409 );
410 if (!published) yield* input.validate(paths);
411 return paths;
412 }).pipe(
413 Effect.ensuring(fs.remove(stagingDir, { recursive: true, force: true }).pipe(Effect.ignore)),
414 );
415});
416
417export const ensurePinnedRuntimeInstalled = (input: PinnedRuntimeInstallInput) =>
418 pinnedRuntimeInstallLock.withPermit(installPinnedRuntime(input));
419
420const PINNED_RUNTIME_PRUNED_PREFIX = ".pruned-";
421/**
422 * Another process may have just published a runtime it has not yet pointed anything at: an
423 * update, a downgrade, or the install script between unpacking and linking. The install lock only
424 * covers this process, so recently completed runtimes are left for a later prune.
425 */
426const PINNED_RUNTIME_PRUNE_GRACE = Duration.hours(1);
427/**
428 * One `t3` launcher path per line, appended by the install scripts and by any prune that finds a
429 * launcher on PATH. A service does not see the installer's `T3CODE_INSTALL_BIN_DIR` or the
430 * interactive PATH, so this is how it finds a custom one, including installs that predate the file.
431 */
432const PINNED_RUNTIME_LAUNCHERS_FILE = "launchers";
433
434/**
435 * Starts on a fresh line whatever the file ends with when the append lands: another process's
436 * append may have been cut short since this one read the file. Readers skip the blank lines.
437 */
438const launcherRecords = (launchers: ReadonlyArray<string>) =>
439 `\n${launchers.map((launcher) => `${launcher}\n`).join("")}`;
440
441export function launcherOwnsVersionsDir(
442 path: Path.Path,
443 versionsDir: string,
444 candidate: string,
445): boolean {
446 const relative = path.relative(versionsDir, path.resolve(candidate));
447 return relative.length > 0 && !relative.startsWith("..") && !path.isAbsolute(relative);
448}
449
450/** Node preserves argv0's spelling; resolve bare names on PATH to find the launcher symlink. */
451export const resolveLauncherPath = Effect.gen(function* () {
452 const path = yield* Path.Path;
453 const fs = yield* FileSystem.FileSystem;
454 const invokedAs = yield* HostProcess.InvokedAs;
455 const cwd = yield* HostProcess.WorkingDirectory;
456 const environment = yield* HostProcess.Environment;
457 const platform = yield* HostProcess.Platform;
458 if (invokedAs.includes("/") || invokedAs.includes("\\")) {
459 return path.resolve(cwd, invokedAs);
460 }
461 const delimiter = platform === "win32" ? ";" : ":";
462 for (const directory of (environment["PATH"] ?? "").split(delimiter)) {
463 if (directory.length === 0) continue;
464 const candidate = path.resolve(cwd, directory, invokedAs);
465 const info = yield* fs.stat(candidate).pipe(Effect.option);
466 // Like the shell, pass over directories and non-executable files named the same.
467 if (
468 Option.isSome(info) &&
469 info.value.type === "File" &&
470 (platform === "win32" || (info.value.mode & 0o111) !== 0)
471 ) {
472 return candidate;
473 }
474 }
475 return undefined;
476});
477
478/**
479 * `t3 update` repoints the launcher it was started through, which may be on neither PATH nor the
480 * default bin dir. Unrecorded, the next prune would remove the runtime it now points at.
481 */
482export const recordPinnedRuntimeLauncher = Effect.fn("cloud.pinned_runtime.record_launcher")(
483 function* (input: { readonly baseDir: string; readonly launcher: string }) {
484 const fs = yield* FileSystem.FileSystem;
485 const path = yield* Path.Path;
486 const launchersFile = path.join(
487 input.baseDir,
488 PINNED_RUNTIME_DIR,
489 PINNED_RUNTIME_LAUNCHERS_FILE,
490 );
491 // Prune may run in another process, such as the service, whose working directory differs.
492 const launcher = path.resolve(input.launcher);
493 const recorded = yield* fs.readFileString(launchersFile).pipe(Effect.orElseSucceed(() => ""));
494 if (recorded.split(/\r?\n/).includes(launcher)) return;
495 yield* fs.writeFileString(launchersFile, launcherRecords([launcher]), { flag: "a" });
496 },
497);
498
499/** Whether `candidate` is a version's own executable, `<versionsDir>/<version>/t3[.exe]`. */
500export function isVersionExecutable(
501 path: Path.Path,
502 versionsDir: string,
503 candidate: string,
504): boolean {
505 const relative = path.relative(versionsDir, path.resolve(candidate));
506 // On another Windows drive, relative() returns the absolute path, like `D:\t3.exe`.
507 return !path.isAbsolute(relative) && /^(?!\.\.)[^\\/]+[\\/]t3(?:\.exe)?$/i.test(relative);
508}
509
510/** The executable a `t3.cmd` shim written by the install script or `t3 update` runs. */
511export const windowsShimTarget = (contents: string) => /^"([^"]+)"/m.exec(contents)?.[1];
512
513/** Like {@link windowsShimTarget}, but only for a shim exactly as written; never a user's wrapper. */
514export const generatedWindowsShimTarget = (contents: string) =>
515 /^@echo off\r?\n"([^"]+)" %\*\s*$/.exec(contents)?.[1];
516
517/** Where the `t3` launchers written by the install scripts and `t3 update` point. */
518const shellLauncherTargets = Effect.fn("cloud.pinned_runtime.shell_launcher_targets")(function* (
519 baseDir: string,
520) {
521 const fs = yield* FileSystem.FileSystem;
522 const path = yield* Path.Path;
523 const platform = yield* HostProcess.Platform;
524 const environment = yield* HostProcess.Environment;
525 const home = yield* HostProcess.HomeDirectory;
526 const launcherName = platform === "win32" ? "t3.cmd" : "t3";
527 const launchersFile = path.join(baseDir, PINNED_RUNTIME_DIR, PINNED_RUNTIME_LAUNCHERS_FILE);
528 const recorded = (yield* fs.readFileString(launchersFile).pipe(Effect.orElseSucceed(() => "")))
529 .split(/\r?\n/)
530 .filter((launcher) => launcher.trim().length > 0);
531 const found = [
532 environment["T3CODE_INSTALL_BIN_DIR"] || path.join(home, ".local", "bin"),
533 ...(environment["PATH"] ?? environment["Path"] ?? "").split(platform === "win32" ? ";" : ":"),
534 ]
535 .filter((directory) => path.isAbsolute(directory))
536 .map((directory) => path.join(directory, launcherName));
537 const versionsDir = pinnedRuntimeVersionsDir(path, baseDir);
538 const realVersionsDir = yield* fs
539 .realPath(versionsDir)
540 .pipe(Effect.orElseSucceed(() => versionsDir));
541 const inVersionsDir = (candidate: string) =>
542 launcherOwnsVersionsDir(path, versionsDir, candidate) ||
543 launcherOwnsVersionsDir(path, realVersionsDir, candidate);
544 const unrecorded: Array<string> = [];
545 const targets: Array<string> = [];
546 for (const launcher of [...recorded, ...found]) {
547 const target =
548 platform === "win32"
549 ? yield* fs.readFileString(launcher).pipe(
550 Effect.map(windowsShimTarget),
551 Effect.orElseSucceed(() => undefined),
552 )
553 : // Fully resolved, through chained links and a symlinked T3 home alike.
554 yield* fs.realPath(launcher).pipe(Effect.orElseSucceed(() => undefined));
555 if (target === undefined) continue;
556 // A runtime's own entry, on the macOS service PATH or recorded by an earlier prune, is no launcher.
557 // It is named as `versions/<version>/t3`, and neither it nor its directory is a link.
558 const entryDir = path.dirname(path.resolve(launcher));
559 const parent = path.dirname(entryDir);
560 if (
561 platform !== "win32" &&
562 (parent === versionsDir || parent === realVersionsDir) &&
563 target === path.join(realVersionsDir, path.basename(entryDir), path.basename(launcher))
564 ) {
565 continue;
566 }
567 targets.push(target);
568 if (inVersionsDir(target) && !recorded.includes(launcher) && !unrecorded.includes(launcher)) {
569 unrecorded.push(launcher);
570 }
571 }
572 if (unrecorded.length > 0) {
573 yield* fs
574 .writeFileString(launchersFile, launcherRecords(unrecorded), { flag: "a" })
575 .pipe(Effect.ignore);
576 }
577 return targets;
578});
579
580/**
581 * Resolved executables rather than command lines: a server started through the `t3` symlink
582 * shows up as `t3 serve`, possibly from another T3 home that runs this home's runtimes. Windows
583 * has no symlink launcher, and its `t3.cmd` shim runs the absolute path, so command lines do there.
584 */
585const runningExecutables = Effect.fn("cloud.pinned_runtime.running_executables")(function* () {
586 const fs = yield* FileSystem.FileSystem;
587 const runner = yield* ProcessRunner.ProcessRunner;
588 const platform = yield* HostProcess.Platform;
589 if (platform === "linux") {
590 const pids = yield* fs.readDirectory("/proc").pipe(Effect.option);
591 if (Option.isNone(pids)) return Option.none<string>();
592 const executables: Array<string> = [];
593 for (const pid of pids.value) {
594 if (!/^\d+$/.test(pid)) continue;
595 // Unreadable ones belong to other users or are this user's non-dumpable daemons (systemd
596 // --user, ssh-agent). Neither runs a t3 runtime, and bailing on them would never prune.
597 const executable = yield* fs.readLink(`/proc/${pid}/exe`).pipe(Effect.option);
598 if (Option.isSome(executable)) executables.push(executable.value);
599 }
600 return executables.length === 0 ? Option.none<string>() : Option.some(executables.join("\n"));
601 }
602 const result = yield* runner
603 .run({
604 ...(platform === "win32"
605 ? {
606 command: "powershell.exe",
607 args: [
608 "-NoProfile",
609 "-NonInteractive",
610 "-Command",
611 "Get-CimInstance Win32_Process | Select-Object -ExpandProperty CommandLine",
612 ],
613 }
614 : { command: "lsof", args: ["-nP", "-w", "-d", "txt", "-F", "n"] }),
615 timeout: Duration.seconds(10),
616 maxOutputBytes: 16 * 1024 * 1024,
617 })
618 .pipe(Effect.option);
619 return Option.isSome(result) && result.value.code === 0 && result.value.stdout.length > 0
620 ? Option.some(result.value.stdout)
621 : Option.none<string>();
622});
623
624const selectUnusedRuntimes = Effect.fn("cloud.pinned_runtime.select_unused")(function* (input: {
625 readonly baseDir: string;
626 readonly keep: ReadonlyArray<string>;
627 readonly references: ReadonlyArray<string>;
628}) {
629 const fs = yield* FileSystem.FileSystem;
630 const path = yield* Path.Path;
631 const versionsDir = pinnedRuntimeVersionsDir(path, input.baseDir);
632 const entries = yield* fs.readDirectory(versionsDir).pipe(Effect.orElseSucceed(() => []));
633 // An interrupted prune has already renamed its runtime out of the way.
634 const pruned = new Set(
635 entries
636 .filter((entry) => entry.startsWith(PINNED_RUNTIME_PRUNED_PREFIX))
637 .map((entry) => path.join(versionsDir, entry)),
638 );
639
640 const settledBefore =
641 (yield* Clock.currentTimeMillis) - Duration.toMillis(PINNED_RUNTIME_PRUNE_GRACE);
642 const completed: Array<string> = [];
643 for (const version of entries) {
644 if (!isExactServiceVersion(version)) continue;
645 const versionDir = path.join(versionsDir, version);
646 const sentinelPath = path.join(versionDir, ".install-complete");
647 const isLink = yield* fs.readLink(versionDir).pipe(Effect.isSuccess);
648 const sentinel = yield* fs.readFileString(sentinelPath).pipe(Effect.option);
649 const completedAt = yield* fs.stat(sentinelPath).pipe(
650 Effect.map((info) => Option.getOrUndefined(info.mtime)?.getTime()),
651 Effect.orElseSucceed(() => undefined),
652 );
653 if (
654 !isLink &&
655 Option.isSome(sentinel) &&
656 sentinel.value.trim() === version &&
657 completedAt !== undefined &&
658 completedAt < settledBefore
659 ) {
660 completed.push(version);
661 }
662 }
663 if (completed.length === 0) return pruned;
664
665 const keep = new Set(input.keep);
666 const stateText = yield* fs
667 .readFileString(path.join(input.baseDir, PINNED_RUNTIME_DIR, SERVICE_STATE_FILE))
668 .pipe(
669 Effect.asSome,
670 Effect.catch((error) =>
671 Effect.succeed(error.reason._tag === "NotFound" ? Option.none<string>() : undefined),
672 ),
673 );
674 // State this version cannot read may reference runtimes in ways it does not know.
675 if (stateText === undefined) return pruned;
676 if (Option.isSome(stateText)) {
677 const state = parseServiceState(stateText.value);
678 if (state === undefined) return pruned;
679 keep.add(state.activeVersion);
680 if (state.update !== undefined) {
681 keep.add(state.update.fromVersion);
682 keep.add(state.update.targetVersion);
683 }
684 }
685 const executables = yield* runningExecutables();
686 // Without a process list, a runtime in use would look unused.
687 if (Option.isNone(executables)) return pruned;
688 const platform = yield* HostProcess.Platform;
689 // Windows paths are case-insensitive, and shims and process lists need not agree on case.
690 const foldCase = (value: string) => (platform === "win32" ? value.toLowerCase() : value);
691 const references = [
692 executables.value,
693 ...(yield* shellLauncherTargets(input.baseDir)),
694 ...input.references,
695 ].map(foldCase);
696 // Process executables are resolved, so they name the real path of a symlinked T3 home.
697 const realVersionsDir = yield* fs
698 .realPath(versionsDir)
699 .pipe(Effect.orElseSucceed(() => versionsDir));
700
701 for (const version of completed) {
702 const versionDir = path.join(versionsDir, version);
703 const prefixes = [versionDir, path.join(realVersionsDir, version)].map((dir) =>
704 foldCase(dir + path.sep),
705 );
706 if (
707 keep.has(version) ||
708 references.some((reference) => prefixes.some((prefix) => reference.includes(prefix)))
709 ) {
710 continue;
711 }
712 const target = path.join(versionsDir, `${PINNED_RUNTIME_PRUNED_PREFIX}${version}`);
713 // Renaming first means an interrupted removal never leaves a partial tree that still carries
714 // its sentinel and would pass for a complete install.
715 yield* fs.remove(target, { recursive: true, force: true }).pipe(
716 Effect.andThen(fs.rename(versionDir, target)),
717 Effect.andThen(Effect.sync(() => pruned.add(target))),
718 Effect.catch((cause) =>
719 Effect.logWarning("Could not remove an unused t3 runtime.", { version, cause }),
720 ),
721 );
722 }
723 return pruned;
724});
725
726/**
727 * Keep runtimes named by callers, service state, running processes, launchers, or references,
728 * and anything published within an hour. Only prune completed runtimes; leave staging
729 * directories, symlinks, and unexpected entries untouched.
730 */
731export const prunePinnedRuntimes = Effect.fn("cloud.pinned_runtime.prune")(function* (input: {
732 readonly baseDir: string;
733 readonly keep: ReadonlyArray<string>;
734 readonly references?: ReadonlyArray<string>;
735}) {
736 const fs = yield* FileSystem.FileSystem;
737 const path = yield* Path.Path;
738 // Only selection and the renames race an install; deleting renamed trees can take a while.
739 const pruned = yield* pinnedRuntimeInstallLock.withPermit(
740 selectUnusedRuntimes({ ...input, references: input.references ?? [] }),
741 );
742 const removed: Array<string> = [];
743 for (const target of pruned) {
744 yield* fs.remove(target, { recursive: true, force: true }).pipe(
745 Effect.andThen(
746 Effect.sync(() =>
747 removed.push(path.basename(target).slice(PINNED_RUNTIME_PRUNED_PREFIX.length)),
748 ),
749 ),
750 Effect.catch((cause) =>
751 Effect.logWarning("Could not remove an unused t3 runtime.", { target, cause }),
752 ),
753 );
754 }
755 return removed;
756});