From 9dcf579bae2d07c4675d83267cb04b0cd07a9a58 Mon Sep 17 00:00:00 2001 From: Victor Vogel Date: Fri, 23 Oct 2026 15:52:39 -0500 Subject: [PATCH 1/6] fix(server): t3 update records the launcher it repoints t3 update repointed the launcher it was started through without adding it to runtime/launchers. A launcher on neither PATH nor the default bin dir, such as /t3, was then unknown to prune, so the next server start deleted the runtime it pointed at. repointLauncher now records it. Co-Authored-By: Claude Opus 5.5 --- apps/server/src/cli/update.test.ts | 59 +++++++++++++++++++++++++- apps/server/src/cli/update.ts | 21 ++++++--- apps/server/src/cloud/pinnedRuntime.ts | 19 +++++++++ 3 files changed, 91 insertions(+), 8 deletions(-) diff --git a/apps/server/src/cli/update.test.ts b/apps/server/src/cli/update.test.ts index e6a6eb5ee1db..23eff5fbb6dd 100644 --- a/apps/server/src/cli/update.test.ts +++ b/apps/server/src/cli/update.test.ts @@ -1,15 +1,35 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, it } from "@effect/vitest"; import * as HostProcess from "@t3tools/shared/HostProcess"; +import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Option from "effect/Option"; import * as Path from "effect/Path"; +import * as TestClock from "effect/testing/TestClock"; import { HttpClient, HttpClientResponse } from "effect/http"; +import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; import { afterEach, vi } from "vite-plus/test"; +import { prunePinnedRuntimes } from "../cloud/pinnedRuntime.ts"; +import * as ProcessRunner from "../processRunner.ts"; import { repointLauncher, resolveLauncherPath, resolveNewestVersion } from "./update.ts"; +// lsof listing one process, so prune sees a process list in which no runtime is running. +const unrelatedLsof = ProcessRunner.ProcessRunner.of({ + run: () => + Effect.succeed({ + stdout: "p1\nftxt\nn/usr/bin/true\n", + stderr: "", + code: ChildProcessSpawner.ExitCode(0), + timedOut: false, + stdoutTruncated: false, + stderrTruncated: false, + stdoutInvalidUtf8: false, + stderrInvalidUtf8: false, + }), +}); + afterEach(() => vi.restoreAllMocks()); it.effect("authenticates each release page until the requested channel is found", () => @@ -53,7 +73,7 @@ it.layer(NodeServices.layer)("t3 update launcher", (it) => { const repointed = yield* repointLauncher({ launchedAs: launcher, - versionsDir: path.join(root, "runtime/versions"), + baseDir: root, targetEntryPath: newExe, }); @@ -62,6 +82,41 @@ it.layer(NodeServices.layer)("t3 update launcher", (it) => { }).pipe(Effect.scoped, Effect.provideService(HostProcess.Platform, "linux")), ); + it.effect("keeps the runtime a repointed launcher off PATH points at", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-update-" }); + yield* TestClock.setTime(Duration.toMillis(Duration.days(1))); + for (const version of ["1.0.0", "2.0.0"]) { + const directory = path.join(root, "runtime/versions", version); + yield* fs.makeDirectory(directory, { recursive: true }); + yield* fs.writeFileString(path.join(directory, "t3"), ""); + yield* fs.writeFileString(path.join(directory, ".install-complete"), `${version}\n`); + yield* fs.utimes(path.join(directory, ".install-complete"), 0, 0); + } + // A launcher in the T3 home itself, which neither PATH nor the default bin dir reaches. + const launcher = path.join(root, "t3"); + yield* fs.symlink(path.join(root, "runtime/versions/1.0.0/t3"), launcher); + + yield* repointLauncher({ + launchedAs: launcher, + baseDir: root, + targetEntryPath: path.join(root, "runtime/versions/2.0.0/t3"), + }); + // A later server start prunes with no idea how this machine launches t3. + const removed = yield* prunePinnedRuntimes({ baseDir: root, keep: [] }).pipe( + Effect.provideService(ProcessRunner.ProcessRunner, unrelatedLsof), + Effect.provideService(HostProcess.Platform, "darwin"), + Effect.provideService(HostProcess.Environment, { PATH: "" }), + Effect.provideService(HostProcess.HomeDirectory, root), + ); + + assert.deepStrictEqual(removed, ["1.0.0"]); + assert.equal(yield* fs.realPath(launcher), path.join(root, "runtime/versions/2.0.0/t3")); + }).pipe(Effect.scoped, Effect.provideService(HostProcess.Platform, "linux")), + ); + it.effect("leaves a plain copy or a foreign symlink alone", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; @@ -85,7 +140,7 @@ it.layer(NodeServices.layer)("t3 update launcher", (it) => { for (const launchedAs of [copy, foreign, otherLauncher, undefined]) { const repointed = yield* repointLauncher({ launchedAs, - versionsDir: path.join(root, "runtime/versions"), + baseDir: root, targetEntryPath: newExe, }); assert.equal(repointed._tag, "None", launchedAs ?? "undefined"); diff --git a/apps/server/src/cli/update.ts b/apps/server/src/cli/update.ts index f5e5eafa8d26..ea13a414789f 100644 --- a/apps/server/src/cli/update.ts +++ b/apps/server/src/cli/update.ts @@ -26,7 +26,9 @@ import { pinnedRuntimeCommand, PinnedRuntimeInstallError, pinnedRuntimePaths, + pinnedRuntimeVersionsDir, prunePinnedRuntimes, + recordPinnedRuntimeLauncher, windowsShimTarget, } from "../cloud/pinnedRuntime.ts"; import { compareExactServiceVersions, isExactServiceVersion } from "../cloud/serviceProtocol.ts"; @@ -96,15 +98,22 @@ export const resolveNewestVersion = Effect.fn("cli.update.resolve_newest")(funct */ export const repointLauncher = Effect.fn("cli.update.repoint_launcher")(function* (input: { readonly launchedAs: string | undefined; - readonly versionsDir: string; + readonly baseDir: string; readonly targetEntryPath: string; }) { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; const platform = yield* HostProcess.Platform; if (input.launchedAs === undefined) return Option.none(); - const ownsTarget = (candidate: string) => - launcherOwnsVersionsDir(path, input.versionsDir, candidate); + const versionsDir = pinnedRuntimeVersionsDir(path, input.baseDir); + const ownsTarget = (candidate: string) => launcherOwnsVersionsDir(path, versionsDir, candidate); + const remember = (launcher: string) => + recordPinnedRuntimeLauncher({ baseDir: input.baseDir, launcher }).pipe( + Effect.catch((cause) => + Effect.logWarning("Could not record the t3 launcher.", { launcher, cause }), + ), + Effect.as(Option.some(launcher)), + ); if (platform === "win32") { const shimPath = yield* findWindowsShim(input.launchedAs); @@ -119,7 +128,7 @@ export const repointLauncher = Effect.fn("cli.update.repoint_launcher")(function () => new CliUpdateError({ reason: `Could not rewrite the t3 launcher at ${shimPath}.` }), ), ); - return Option.some(shimPath); + return yield* remember(shimPath); } const linkTarget = yield* fs.readLink(input.launchedAs).pipe(Effect.option); @@ -134,7 +143,7 @@ export const repointLauncher = Effect.fn("cli.update.repoint_launcher")(function new CliUpdateError({ reason: `Could not repoint the t3 launcher at ${input.launchedAs}.` }), ), ); - return Option.some(input.launchedAs); + return yield* remember(input.launchedAs); }); /** @@ -482,7 +491,7 @@ const runUpdate = Effect.fn("cli.update.run")(function* (input: { const launchedAs = (yield* HostProcess.IsExecutable) ? yield* resolveLauncherPath : undefined; const repointed = yield* repointLauncher({ launchedAs, - versionsDir: path.dirname(runtime.versionDir), + baseDir: input.baseDir, targetEntryPath: runtime.entryPath, }); diff --git a/apps/server/src/cloud/pinnedRuntime.ts b/apps/server/src/cloud/pinnedRuntime.ts index 0093e7a6220e..3ee4df80ff65 100644 --- a/apps/server/src/cloud/pinnedRuntime.ts +++ b/apps/server/src/cloud/pinnedRuntime.ts @@ -392,6 +392,25 @@ export function launcherOwnsVersionsDir( return relative.length > 0 && !relative.startsWith("..") && !path.isAbsolute(relative); } +/** + * `t3 update` repoints the launcher it was started through, which may be on neither PATH nor the + * default bin dir. Unrecorded, the next prune would remove the runtime it now points at. + */ +export const recordPinnedRuntimeLauncher = Effect.fn("cloud.pinned_runtime.record_launcher")( + function* (input: { readonly baseDir: string; readonly launcher: string }) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const launchersFile = path.join( + input.baseDir, + PINNED_RUNTIME_DIR, + PINNED_RUNTIME_LAUNCHERS_FILE, + ); + const recorded = yield* fs.readFileString(launchersFile).pipe(Effect.orElseSucceed(() => "")); + if (recorded.split(/\r?\n/).includes(input.launcher)) return; + yield* fs.writeFileString(launchersFile, `${input.launcher}\n`, { flag: "a" }); + }, +); + /** The executable a `t3.cmd` shim written by the install script or `t3 update` runs. */ export const windowsShimTarget = (contents: string) => /^"([^"]+)"/m.exec(contents)?.[1]; From 44908dfaf484e0f7e6b3b6e0dc69fc0cbb8a7677 Mon Sep 17 00:00:00 2001 From: Victor Vogel Date: Fri, 23 Oct 2026 16:44:45 -0500 Subject: [PATCH 2/6] fix(server): t3 update records the launcher before repointing it repointLauncher moved the launcher first and recorded it after. A crash or failed write between the two steps left a repointed launcher that prune does not know about, so the next start could delete its runtime. The launcher is now recorded before it moves, on POSIX and Windows. A record failure fails the update with a CliUpdateError and leaves the launcher where it was. An extra record when the repoint then fails is harmless: prune keeps whatever a recorded launcher points at. Co-Authored-By: Claude Opus 5.5 --- apps/server/src/cli/update.test.ts | 70 ++++++++++++++++++++++++++++++ apps/server/src/cli/update.ts | 14 +++--- 2 files changed, 78 insertions(+), 6 deletions(-) diff --git a/apps/server/src/cli/update.test.ts b/apps/server/src/cli/update.test.ts index 23eff5fbb6dd..2ae938908956 100644 --- a/apps/server/src/cli/update.test.ts +++ b/apps/server/src/cli/update.test.ts @@ -3,6 +3,7 @@ import { assert, it } from "@effect/vitest"; import * as HostProcess from "@t3tools/shared/HostProcess"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; import * as FileSystem from "effect/FileSystem"; import * as Option from "effect/Option"; import * as Path from "effect/Path"; @@ -117,6 +118,75 @@ it.layer(NodeServices.layer)("t3 update launcher", (it) => { }).pipe(Effect.scoped, Effect.provideService(HostProcess.Platform, "linux")), ); + const launcherFixture = Effect.fnUntraced(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-update-" }); + const oldExe = path.join(root, "runtime/versions/1.0.0/t3"); + const newExe = path.join(root, "runtime/versions/2.0.0/t3"); + const launcher = path.join(root, "t3"); + for (const file of [oldExe, newExe]) { + yield* fs.makeDirectory(path.dirname(file), { recursive: true }); + yield* fs.writeFileString(file, ""); + } + yield* fs.symlink(oldExe, launcher); + return { root, oldExe, newExe, launcher, launchersFile: path.join(root, "runtime/launchers") }; + }); + + it.effect("fails instead of repointing a launcher it cannot record", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const { root, oldExe, newExe, launcher, launchersFile } = yield* launcherFixture(); + // Appending to a directory fails the way a read-only or full disk would. + yield* fs.makeDirectory(launchersFile); + + const result = yield* repointLauncher({ + launchedAs: launcher, + baseDir: root, + targetEntryPath: newExe, + }).pipe(Effect.exit); + + assert.isTrue(Exit.isFailure(result)); + assert.equal(yield* fs.readLink(launcher), oldExe); + }).pipe(Effect.scoped, Effect.provideService(HostProcess.Platform, "linux")), + ); + + it.effect.each(["recording", "repointing"] as const)( + "never leaves a repointed launcher unrecorded when t3 update dies while %s", + (step) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const { root, newExe, launcher, launchersFile } = yield* launcherFixture(); + + yield* repointLauncher({ + launchedAs: launcher, + baseDir: root, + targetEntryPath: newExe, + }).pipe( + Effect.provideService(FileSystem.FileSystem, { + ...fs, + writeFileString: (path, data, options) => + step === "recording" && path === launchersFile + ? Effect.die("t3 update was killed") + : fs.writeFileString(path, data, options), + rename: (from, to) => + step === "repointing" && to === launcher + ? Effect.die("t3 update was killed") + : fs.rename(from, to), + }), + Effect.exit, + ); + + const repointed = (yield* fs.readLink(launcher)) === newExe; + const recorded = (yield* fs + .readFileString(launchersFile) + .pipe(Effect.orElseSucceed(() => ""))) + .split("\n") + .includes(launcher); + assert.isFalse(repointed && !recorded); + }).pipe(Effect.scoped, Effect.provideService(HostProcess.Platform, "linux")), + ); + it.effect("leaves a plain copy or a foreign symlink alone", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; diff --git a/apps/server/src/cli/update.ts b/apps/server/src/cli/update.ts index ea13a414789f..965e00d3b7db 100644 --- a/apps/server/src/cli/update.ts +++ b/apps/server/src/cli/update.ts @@ -94,7 +94,8 @@ export const resolveNewestVersion = Effect.fn("cli.update.resolve_newest")(funct /** * Only a launcher that already points into this home's `runtime/versions` tree is touched; a - * plain copy of the executable, or a launcher for some other install, is left alone. + * plain copy of the executable, or a launcher for some other install, is left alone. It is + * recorded before it moves, so prune never meets a repointed launcher it does not know about. */ export const repointLauncher = Effect.fn("cli.update.repoint_launcher")(function* (input: { readonly launchedAs: string | undefined; @@ -109,10 +110,9 @@ export const repointLauncher = Effect.fn("cli.update.repoint_launcher")(function const ownsTarget = (candidate: string) => launcherOwnsVersionsDir(path, versionsDir, candidate); const remember = (launcher: string) => recordPinnedRuntimeLauncher({ baseDir: input.baseDir, launcher }).pipe( - Effect.catch((cause) => - Effect.logWarning("Could not record the t3 launcher.", { launcher, cause }), + Effect.mapError( + () => new CliUpdateError({ reason: `Could not record the t3 launcher at ${launcher}.` }), ), - Effect.as(Option.some(launcher)), ); if (platform === "win32") { @@ -121,6 +121,7 @@ export const repointLauncher = Effect.fn("cli.update.repoint_launcher")(function const current = yield* fs.readFileString(shimPath).pipe(Effect.option); const quoted = Option.isSome(current) ? windowsShimTarget(current.value) : undefined; if (quoted === undefined || !ownsTarget(quoted)) return Option.none(); + yield* remember(shimPath); yield* fs .writeFileString(shimPath, `@echo off\r\n"${input.targetEntryPath}" %*`) .pipe( @@ -128,13 +129,14 @@ export const repointLauncher = Effect.fn("cli.update.repoint_launcher")(function () => new CliUpdateError({ reason: `Could not rewrite the t3 launcher at ${shimPath}.` }), ), ); - return yield* remember(shimPath); + return Option.some(shimPath); } const linkTarget = yield* fs.readLink(input.launchedAs).pipe(Effect.option); if (Option.isNone(linkTarget)) return Option.none(); const resolvedTarget = path.resolve(path.dirname(input.launchedAs), linkTarget.value); if (!ownsTarget(resolvedTarget)) return Option.none(); + yield* remember(input.launchedAs); const tempLink = `${input.launchedAs}.${process.pid}.tmp`; yield* fs.symlink(input.targetEntryPath, tempLink).pipe( Effect.andThen(fs.rename(tempLink, input.launchedAs)), @@ -143,7 +145,7 @@ export const repointLauncher = Effect.fn("cli.update.repoint_launcher")(function new CliUpdateError({ reason: `Could not repoint the t3 launcher at ${input.launchedAs}.` }), ), ); - return yield* remember(input.launchedAs); + return Option.some(input.launchedAs); }); /** From 823f7e88ce7327a28c28b15b5cdea3a1ade2de39 Mon Sep 17 00:00:00 2001 From: Victor Vogel Date: Fri, 23 Oct 2026 20:44:57 -0500 Subject: [PATCH 3/6] fix(server): record t3 launchers by absolute path A launcher found through a relative PATH entry was recorded as typed, so a prune running from another working directory, such as the service, could not find it and removed the runtime it points at. Co-Authored-By: Claude Opus 5.5 --- apps/server/src/cli/update.test.ts | 16 ++++++++++++++++ apps/server/src/cloud/pinnedRuntime.ts | 6 ++++-- 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/apps/server/src/cli/update.test.ts b/apps/server/src/cli/update.test.ts index 2ae938908956..4e900876565d 100644 --- a/apps/server/src/cli/update.test.ts +++ b/apps/server/src/cli/update.test.ts @@ -133,6 +133,22 @@ it.layer(NodeServices.layer)("t3 update launcher", (it) => { return { root, oldExe, newExe, launcher, launchersFile: path.join(root, "runtime/launchers") }; }); + it.effect("records a relative launcher path as absolute", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const { root, newExe, launcher, launchersFile } = yield* launcherFixture(); + + yield* repointLauncher({ + launchedAs: path.relative(path.resolve("."), launcher), + baseDir: root, + targetEntryPath: newExe, + }); + + assert.equal(yield* fs.readFileString(launchersFile), `${launcher}\n`); + }).pipe(Effect.scoped, Effect.provideService(HostProcess.Platform, "linux")), + ); + it.effect("fails instead of repointing a launcher it cannot record", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; diff --git a/apps/server/src/cloud/pinnedRuntime.ts b/apps/server/src/cloud/pinnedRuntime.ts index 3ee4df80ff65..d0aa82ebcfcc 100644 --- a/apps/server/src/cloud/pinnedRuntime.ts +++ b/apps/server/src/cloud/pinnedRuntime.ts @@ -405,9 +405,11 @@ export const recordPinnedRuntimeLauncher = Effect.fn("cloud.pinned_runtime.recor PINNED_RUNTIME_DIR, PINNED_RUNTIME_LAUNCHERS_FILE, ); + // Prune may run in another process, such as the service, whose working directory differs. + const launcher = path.resolve(input.launcher); const recorded = yield* fs.readFileString(launchersFile).pipe(Effect.orElseSucceed(() => "")); - if (recorded.split(/\r?\n/).includes(input.launcher)) return; - yield* fs.writeFileString(launchersFile, `${input.launcher}\n`, { flag: "a" }); + if (recorded.split(/\r?\n/).includes(launcher)) return; + yield* fs.writeFileString(launchersFile, `${launcher}\n`, { flag: "a" }); }, ); From 8be7c2938f16573cacd639f6171d64cdc338da44 Mon Sep 17 00:00:00 2001 From: Victor Vogel Date: Fri, 30 Oct 2026 22:29:32 +0000 Subject: [PATCH 4/6] fix(server): an interrupted launcher record no longer hides the next one from prune The runtime launchers file is appended one path per line. If an append was cut short, its record had no line ending, so the next append joined onto it and neither path stayed a whole line. Prune then failed to find the launcher that t3 update had just repointed and could delete the runtime it runs. Both appends now end an unterminated last record before writing their own, inside the same append, so concurrent writers still never lose a record. Co-Authored-By: Claude Opus 5.5 --- apps/server/src/cli/update.test.ts | 12 ++++++++- apps/server/src/cloud/pinnedRuntime.test.ts | 27 +++++++++++++++++++++ apps/server/src/cloud/pinnedRuntime.ts | 16 ++++++------ 3 files changed, 47 insertions(+), 8 deletions(-) diff --git a/apps/server/src/cli/update.test.ts b/apps/server/src/cli/update.test.ts index 4e900876565d..93b037899b05 100644 --- a/apps/server/src/cli/update.test.ts +++ b/apps/server/src/cli/update.test.ts @@ -83,7 +83,14 @@ it.layer(NodeServices.layer)("t3 update launcher", (it) => { }).pipe(Effect.scoped, Effect.provideService(HostProcess.Platform, "linux")), ); - it.effect("keeps the runtime a repointed launcher off PATH points at", () => + it.effect.each([ + { name: "no launcher records", recorded: undefined }, + { name: "an empty launcher record", recorded: "" }, + { name: "whole launcher records", recorded: "/opt/t3\n" }, + { name: "Windows launcher records", recorded: "C:\\t3.cmd\r\n" }, + // An interrupted append cut this record short. + { name: "an unterminated launcher record", recorded: "/opt/t" }, + ])("keeps the runtime a repointed launcher off PATH points at with $name", ({ recorded }) => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; @@ -96,6 +103,9 @@ it.layer(NodeServices.layer)("t3 update launcher", (it) => { yield* fs.writeFileString(path.join(directory, ".install-complete"), `${version}\n`); yield* fs.utimes(path.join(directory, ".install-complete"), 0, 0); } + if (recorded !== undefined) { + yield* fs.writeFileString(path.join(root, "runtime/launchers"), recorded); + } // A launcher in the T3 home itself, which neither PATH nor the default bin dir reaches. const launcher = path.join(root, "t3"); yield* fs.symlink(path.join(root, "runtime/versions/1.0.0/t3"), launcher); diff --git a/apps/server/src/cloud/pinnedRuntime.test.ts b/apps/server/src/cloud/pinnedRuntime.test.ts index 9d536f471e76..a1ee9f016767 100644 --- a/apps/server/src/cloud/pinnedRuntime.test.ts +++ b/apps/server/src/cloud/pinnedRuntime.test.ts @@ -428,6 +428,33 @@ it.layer(NodeServices.layer)("prunePinnedRuntimes", (it) => { }), ); + it.effect("remembers a launcher found on PATH after an unterminated launcher record", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const { baseDir, versionsDir } = yield* setup(["1.0.0", "1.1.0"]); + const binDir = path.join(baseDir, "custom-bin"); + yield* fs.makeDirectory(binDir); + yield* fs.symlink(path.join(versionsDir, "1.0.0", "t3"), path.join(binDir, "t3")); + // An interrupted append cut this record short. + yield* fs.writeFileString(path.join(baseDir, "runtime", "launchers"), "/opt/t"); + const prune = (environment: Record) => + prunePinnedRuntimes({ baseDir, keep: [] }).pipe( + Effect.provideService(ProcessRunner.ProcessRunner, lsofRunner("p1\nftxt\nn/bin/sh\n")), + Effect.provideService(HostProcess.Platform, "darwin"), + Effect.provideService(HostProcess.Environment, environment), + Effect.provideService(HostProcess.HomeDirectory, baseDir), + ); + + yield* prune({ PATH: binDir }); + // A service that cannot see the interactive PATH. + const removed = yield* prune({}); + + assert.deepEqual(removed, []); + assert.isTrue(yield* fs.exists(path.join(versionsDir, "1.0.0", "t3"))); + }), + ); + it.effect("matches a Windows shim whatever its casing", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; diff --git a/apps/server/src/cloud/pinnedRuntime.ts b/apps/server/src/cloud/pinnedRuntime.ts index d0aa82ebcfcc..eb40acbb9181 100644 --- a/apps/server/src/cloud/pinnedRuntime.ts +++ b/apps/server/src/cloud/pinnedRuntime.ts @@ -383,6 +383,11 @@ const PINNED_RUNTIME_PRUNE_GRACE = Duration.hours(1); */ const PINNED_RUNTIME_LAUNCHERS_FILE = "launchers"; +/** Ends a record an interrupted append left unterminated, so it cannot swallow the next one. */ +const launcherRecords = (recorded: string, launchers: ReadonlyArray) => + (recorded.length === 0 || recorded.endsWith("\n") ? "" : "\n") + + launchers.map((launcher) => `${launcher}\n`).join(""); + export function launcherOwnsVersionsDir( path: Path.Path, versionsDir: string, @@ -409,7 +414,7 @@ export const recordPinnedRuntimeLauncher = Effect.fn("cloud.pinned_runtime.recor const launcher = path.resolve(input.launcher); const recorded = yield* fs.readFileString(launchersFile).pipe(Effect.orElseSucceed(() => "")); if (recorded.split(/\r?\n/).includes(launcher)) return; - yield* fs.writeFileString(launchersFile, `${launcher}\n`, { flag: "a" }); + yield* fs.writeFileString(launchersFile, launcherRecords(recorded, [launcher]), { flag: "a" }); }, ); @@ -427,9 +432,8 @@ const shellLauncherTargets = Effect.fn("cloud.pinned_runtime.shell_launcher_targ const home = yield* HostProcess.HomeDirectory; const launcherName = platform === "win32" ? "t3.cmd" : "t3"; const launchersFile = path.join(baseDir, PINNED_RUNTIME_DIR, PINNED_RUNTIME_LAUNCHERS_FILE); - const recorded = (yield* fs.readFileString(launchersFile).pipe(Effect.orElseSucceed(() => ""))) - .split(/\r?\n/) - .filter((launcher) => launcher.trim().length > 0); + const records = yield* fs.readFileString(launchersFile).pipe(Effect.orElseSucceed(() => "")); + const recorded = records.split(/\r?\n/).filter((launcher) => launcher.trim().length > 0); const found = [ environment["T3CODE_INSTALL_BIN_DIR"] || path.join(home, ".local", "bin"), ...(environment["PATH"] ?? environment["Path"] ?? "").split(platform === "win32" ? ";" : ":"), @@ -464,9 +468,7 @@ const shellLauncherTargets = Effect.fn("cloud.pinned_runtime.shell_launcher_targ } if (unrecorded.length > 0) { yield* fs - .writeFileString(launchersFile, unrecorded.map((launcher) => `${launcher}\n`).join(""), { - flag: "a", - }) + .writeFileString(launchersFile, launcherRecords(records, unrecorded), { flag: "a" }) .pipe(Effect.ignore); } return targets; From e4251199a5dc0b410944ad8ccca4d65a62fdfabc Mon Sep 17 00:00:00 2001 From: Victor Vogel Date: Fri, 30 Oct 2026 22:46:05 +0000 Subject: [PATCH 5/6] fix(server): a launcher record cut short after the read no longer swallows the next one Both server appends to the launchers file decided whether to start a new line from the contents they had read. Another process's append could be cut short between that read and this append, so the new record still joined the partial line and prune deleted the runtime the launcher runs. Every appended batch now starts with a newline, so its first record begins on a fresh line whatever the file ends with when the append lands. Readers already skip the resulting blank lines. Co-Authored-By: Claude Opus 5.5 --- apps/server/src/cli/update.test.ts | 23 +++++++++++++--- apps/server/src/cloud/pinnedRuntime.test.ts | 29 +++++++++++++++++---- apps/server/src/cloud/pinnedRuntime.ts | 19 ++++++++------ 3 files changed, 55 insertions(+), 16 deletions(-) diff --git a/apps/server/src/cli/update.test.ts b/apps/server/src/cli/update.test.ts index 93b037899b05..cfdd41ecc499 100644 --- a/apps/server/src/cli/update.test.ts +++ b/apps/server/src/cli/update.test.ts @@ -90,7 +90,9 @@ it.layer(NodeServices.layer)("t3 update launcher", (it) => { { name: "Windows launcher records", recorded: "C:\\t3.cmd\r\n" }, // An interrupted append cut this record short. { name: "an unterminated launcher record", recorded: "/opt/t" }, - ])("keeps the runtime a repointed launcher off PATH points at with $name", ({ recorded }) => + // Another process's append is cut short after t3 update reads the records. + { name: "a record cut short after the read", recorded: "/opt/t3\n", cut: "/opt/t" }, + ])("keeps the runtime a repointed launcher off PATH points at with $name", ({ recorded, cut }) => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; @@ -110,11 +112,26 @@ it.layer(NodeServices.layer)("t3 update launcher", (it) => { const launcher = path.join(root, "t3"); yield* fs.symlink(path.join(root, "runtime/versions/1.0.0/t3"), launcher); + const launchersFile = path.join(root, "runtime/launchers"); yield* repointLauncher({ launchedAs: launcher, baseDir: root, targetEntryPath: path.join(root, "runtime/versions/2.0.0/t3"), - }); + }).pipe( + Effect.provideService(FileSystem.FileSystem, { + ...fs, + readFileString: (file, encoding) => + fs + .readFileString(file, encoding) + .pipe( + Effect.tap(() => + cut === undefined || file !== launchersFile + ? Effect.void + : fs.writeFileString(file, cut, { flag: "a" }), + ), + ), + }), + ); // A later server start prunes with no idea how this machine launches t3. const removed = yield* prunePinnedRuntimes({ baseDir: root, keep: [] }).pipe( Effect.provideService(ProcessRunner.ProcessRunner, unrelatedLsof), @@ -155,7 +172,7 @@ it.layer(NodeServices.layer)("t3 update launcher", (it) => { targetEntryPath: newExe, }); - assert.equal(yield* fs.readFileString(launchersFile), `${launcher}\n`); + assert.equal(yield* fs.readFileString(launchersFile), `\n${launcher}\n`); }).pipe(Effect.scoped, Effect.provideService(HostProcess.Platform, "linux")), ); diff --git a/apps/server/src/cloud/pinnedRuntime.test.ts b/apps/server/src/cloud/pinnedRuntime.test.ts index a1ee9f016767..5edd29da35ef 100644 --- a/apps/server/src/cloud/pinnedRuntime.test.ts +++ b/apps/server/src/cloud/pinnedRuntime.test.ts @@ -409,7 +409,7 @@ it.layer(NodeServices.layer)("prunePinnedRuntimes", (it) => { // The launcher found on PATH is remembered for a service that cannot see it. assert.equal( yield* fs.readFileString(path.join(baseDir, "runtime", "launchers")), - `${path.join(customBinDir, "t3")}\n${path.join(binDir, "t3")}\n`, + `${path.join(customBinDir, "t3")}\n\n${path.join(binDir, "t3")}\n`, ); assert.deepEqual((yield* fs.readDirectory(versionsDir)).toSorted(), [ ".staging-abc", @@ -428,7 +428,12 @@ it.layer(NodeServices.layer)("prunePinnedRuntimes", (it) => { }), ); - it.effect("remembers a launcher found on PATH after an unterminated launcher record", () => + it.effect.each([ + // An interrupted append cut this record short. + { name: "an unterminated launcher record", recorded: "/opt/t", cut: "" }, + // Another process's append is cut short after prune reads the records. + { name: "a record cut short after the read", recorded: "/opt/t3\n", cut: "/opt/t" }, + ])("remembers a launcher found on PATH after $name", ({ recorded, cut }) => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; @@ -436,8 +441,8 @@ it.layer(NodeServices.layer)("prunePinnedRuntimes", (it) => { const binDir = path.join(baseDir, "custom-bin"); yield* fs.makeDirectory(binDir); yield* fs.symlink(path.join(versionsDir, "1.0.0", "t3"), path.join(binDir, "t3")); - // An interrupted append cut this record short. - yield* fs.writeFileString(path.join(baseDir, "runtime", "launchers"), "/opt/t"); + const launchersFile = path.join(baseDir, "runtime", "launchers"); + yield* fs.writeFileString(launchersFile, recorded); const prune = (environment: Record) => prunePinnedRuntimes({ baseDir, keep: [] }).pipe( Effect.provideService(ProcessRunner.ProcessRunner, lsofRunner("p1\nftxt\nn/bin/sh\n")), @@ -446,7 +451,21 @@ it.layer(NodeServices.layer)("prunePinnedRuntimes", (it) => { Effect.provideService(HostProcess.HomeDirectory, baseDir), ); - yield* prune({ PATH: binDir }); + yield* prune({ PATH: binDir }).pipe( + Effect.provideService(FileSystem.FileSystem, { + ...fs, + readFileString: (file, encoding) => + fs + .readFileString(file, encoding) + .pipe( + Effect.tap(() => + file === launchersFile + ? fs.writeFileString(file, cut, { flag: "a" }) + : Effect.void, + ), + ), + }), + ); // A service that cannot see the interactive PATH. const removed = yield* prune({}); diff --git a/apps/server/src/cloud/pinnedRuntime.ts b/apps/server/src/cloud/pinnedRuntime.ts index eb40acbb9181..0586b362611c 100644 --- a/apps/server/src/cloud/pinnedRuntime.ts +++ b/apps/server/src/cloud/pinnedRuntime.ts @@ -383,10 +383,12 @@ const PINNED_RUNTIME_PRUNE_GRACE = Duration.hours(1); */ const PINNED_RUNTIME_LAUNCHERS_FILE = "launchers"; -/** Ends a record an interrupted append left unterminated, so it cannot swallow the next one. */ -const launcherRecords = (recorded: string, launchers: ReadonlyArray) => - (recorded.length === 0 || recorded.endsWith("\n") ? "" : "\n") + - launchers.map((launcher) => `${launcher}\n`).join(""); +/** + * Starts on a fresh line whatever the file ends with when the append lands: another process's + * append may have been cut short since this one read the file. Readers skip the blank lines. + */ +const launcherRecords = (launchers: ReadonlyArray) => + `\n${launchers.map((launcher) => `${launcher}\n`).join("")}`; export function launcherOwnsVersionsDir( path: Path.Path, @@ -414,7 +416,7 @@ export const recordPinnedRuntimeLauncher = Effect.fn("cloud.pinned_runtime.recor const launcher = path.resolve(input.launcher); const recorded = yield* fs.readFileString(launchersFile).pipe(Effect.orElseSucceed(() => "")); if (recorded.split(/\r?\n/).includes(launcher)) return; - yield* fs.writeFileString(launchersFile, launcherRecords(recorded, [launcher]), { flag: "a" }); + yield* fs.writeFileString(launchersFile, launcherRecords([launcher]), { flag: "a" }); }, ); @@ -432,8 +434,9 @@ const shellLauncherTargets = Effect.fn("cloud.pinned_runtime.shell_launcher_targ const home = yield* HostProcess.HomeDirectory; const launcherName = platform === "win32" ? "t3.cmd" : "t3"; const launchersFile = path.join(baseDir, PINNED_RUNTIME_DIR, PINNED_RUNTIME_LAUNCHERS_FILE); - const records = yield* fs.readFileString(launchersFile).pipe(Effect.orElseSucceed(() => "")); - const recorded = records.split(/\r?\n/).filter((launcher) => launcher.trim().length > 0); + const recorded = (yield* fs.readFileString(launchersFile).pipe(Effect.orElseSucceed(() => ""))) + .split(/\r?\n/) + .filter((launcher) => launcher.trim().length > 0); const found = [ environment["T3CODE_INSTALL_BIN_DIR"] || path.join(home, ".local", "bin"), ...(environment["PATH"] ?? environment["Path"] ?? "").split(platform === "win32" ? ";" : ":"), @@ -468,7 +471,7 @@ const shellLauncherTargets = Effect.fn("cloud.pinned_runtime.shell_launcher_targ } if (unrecorded.length > 0) { yield* fs - .writeFileString(launchersFile, launcherRecords(records, unrecorded), { flag: "a" }) + .writeFileString(launchersFile, launcherRecords(unrecorded), { flag: "a" }) .pipe(Effect.ignore); } return targets; From 2194b299567accf27e7365702bec4a875e0db75e Mon Sep 17 00:00:00 2001 From: Victor Vogel Date: Fri, 30 Oct 2026 23:03:36 +0000 Subject: [PATCH 6/6] test(server): launcher test compares canonical paths so it passes on macOS macOS temp dirs sit behind the /var -> /private/var symlink, so realPath of the launcher never equalled the joined expected path. Resolve both sides. Co-Authored-By: Claude Opus 5.5 --- apps/server/src/cli/update.test.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/apps/server/src/cli/update.test.ts b/apps/server/src/cli/update.test.ts index cfdd41ecc499..0f28a68c3b43 100644 --- a/apps/server/src/cli/update.test.ts +++ b/apps/server/src/cli/update.test.ts @@ -141,7 +141,11 @@ it.layer(NodeServices.layer)("t3 update launcher", (it) => { ); assert.deepStrictEqual(removed, ["1.0.0"]); - assert.equal(yield* fs.realPath(launcher), path.join(root, "runtime/versions/2.0.0/t3")); + // macOS temp dirs sit behind the /var -> /private/var symlink, so compare canonical paths. + assert.equal( + yield* fs.realPath(launcher), + yield* fs.realPath(path.join(root, "runtime/versions/2.0.0/t3")), + ); }).pipe(Effect.scoped, Effect.provideService(HostProcess.Platform, "linux")), );