diff --git a/apps/server/src/cli/update.test.ts b/apps/server/src/cli/update.test.ts index e6a6eb5ee1db..0f28a68c3b43 100644 --- a/apps/server/src/cli/update.test.ts +++ b/apps/server/src/cli/update.test.ts @@ -1,15 +1,36 @@ 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 Exit from "effect/Exit"; 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 +74,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 +83,157 @@ it.layer(NodeServices.layer)("t3 update launcher", (it) => { }).pipe(Effect.scoped, Effect.provideService(HostProcess.Platform, "linux")), ); + 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" }, + // 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; + 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); + } + 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); + + 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), + Effect.provideService(HostProcess.Platform, "darwin"), + Effect.provideService(HostProcess.Environment, { PATH: "" }), + Effect.provideService(HostProcess.HomeDirectory, root), + ); + + assert.deepStrictEqual(removed, ["1.0.0"]); + // 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")), + ); + + 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("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), `\n${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; + 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; @@ -85,7 +257,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..965e00d3b7db 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"; @@ -92,19 +94,26 @@ 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; - 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.mapError( + () => new CliUpdateError({ reason: `Could not record the t3 launcher at ${launcher}.` }), + ), + ); if (platform === "win32") { const shimPath = yield* findWindowsShim(input.launchedAs); @@ -112,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( @@ -126,6 +136,7 @@ export const repointLauncher = Effect.fn("cli.update.repoint_launcher")(function 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)), @@ -482,7 +493,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.test.ts b/apps/server/src/cloud/pinnedRuntime.test.ts index 9d536f471e76..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,6 +428,52 @@ it.layer(NodeServices.layer)("prunePinnedRuntimes", (it) => { }), ); + 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; + 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")); + 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")), + Effect.provideService(HostProcess.Platform, "darwin"), + Effect.provideService(HostProcess.Environment, environment), + Effect.provideService(HostProcess.HomeDirectory, baseDir), + ); + + 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({}); + + 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 0093e7a6220e..0586b362611c 100644 --- a/apps/server/src/cloud/pinnedRuntime.ts +++ b/apps/server/src/cloud/pinnedRuntime.ts @@ -383,6 +383,13 @@ const PINNED_RUNTIME_PRUNE_GRACE = Duration.hours(1); */ const PINNED_RUNTIME_LAUNCHERS_FILE = "launchers"; +/** + * 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, versionsDir: string, @@ -392,6 +399,27 @@ 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, + ); + // 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(launcher)) return; + yield* fs.writeFileString(launchersFile, launcherRecords([launcher]), { 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]; @@ -443,9 +471,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(unrecorded), { flag: "a" }) .pipe(Effect.ignore); } return targets;