Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
176 changes: 174 additions & 2 deletions apps/server/src/cli/update.test.ts
Original file line number Diff line number Diff line change
@@ -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", () =>
Expand Down Expand Up @@ -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,
});

Expand All @@ -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;
Expand All @@ -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");
Expand Down
21 changes: 16 additions & 5 deletions apps/server/src/cli/update.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,9 @@ import {
pinnedRuntimeCommand,
PinnedRuntimeInstallError,
pinnedRuntimePaths,
pinnedRuntimeVersionsDir,
prunePinnedRuntimes,
recordPinnedRuntimeLauncher,
windowsShimTarget,
} from "../cloud/pinnedRuntime.ts";
import { compareExactServiceVersions, isExactServiceVersion } from "../cloud/serviceProtocol.ts";
Expand Down Expand Up @@ -92,26 +94,34 @@ 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<string>();
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);
if (shimPath === undefined) return Option.none<string>();
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<string>();
yield* remember(shimPath);
yield* fs
.writeFileString(shimPath, `@echo off\r\n"${input.targetEntryPath}" %*`)
.pipe(
Expand All @@ -126,6 +136,7 @@ export const repointLauncher = Effect.fn("cli.update.repoint_launcher")(function
if (Option.isNone(linkTarget)) return Option.none<string>();
const resolvedTarget = path.resolve(path.dirname(input.launchedAs), linkTarget.value);
if (!ownsTarget(resolvedTarget)) return Option.none<string>();
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)),
Expand Down Expand Up @@ -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,
});

Expand Down
48 changes: 47 additions & 1 deletion apps/server/src/cloud/pinnedRuntime.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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<string, string>) =>
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;
Expand Down
32 changes: 29 additions & 3 deletions apps/server/src/cloud/pinnedRuntime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>) =>
`\n${launchers.map((launcher) => `${launcher}\n`).join("")}`;

export function launcherOwnsVersionsDir(
path: Path.Path,
versionsDir: string,
Expand All @@ -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];

Expand Down Expand Up @@ -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;
Expand Down
Loading