Skip to content
48 changes: 41 additions & 7 deletions apps/server/src/cloud/bootService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -200,7 +200,7 @@ const makeHarness = Effect.fn("test.make_boot_service_harness")(function* (
const statePath = path.join(baseDir, "runtime", "service-state.json");
const runtime = pinnedRuntimePaths(path, baseDir, "1.2.3", platform);
yield* fs.makeDirectory(path.dirname(runtime.entryPath), { recursive: true });
yield* fs.writeFileString(runtime.entryPath, "#!/bin/sh\n");
yield* fs.writeFileString(runtime.entryPath, "#!/bin/sh\n", { mode: 0o755 });
yield* fs.writeFileString(runtime.sentinelPath, "1.2.3\n");

const commands: string[] = [];
Expand Down Expand Up @@ -344,7 +344,7 @@ const makeHarness = Effect.fn("test.make_boot_service_harness")(function* (
Effect.gen(function* () {
const paths = pinnedRuntimePaths(path, serviceBaseDir, cliVersion, platform);
yield* fs.makeDirectory(path.dirname(paths.entryPath), { recursive: true });
yield* fs.writeFileString(paths.entryPath, "#!/bin/sh\n");
yield* fs.writeFileString(paths.entryPath, "#!/bin/sh\n", { mode: 0o755 });
yield* fs.writeFileString(paths.sentinelPath, `${cliVersion}\n`);
return yield* BootService.make({
baseDir: serviceBaseDir,
Expand Down Expand Up @@ -910,6 +910,8 @@ it.layer(NodeServices.layer)("boot service install", (it) => {
yield* makeHarness(platform);
const path = yield* Path.Path;
const envPath = path.join(baseDir, SERVICE_ENV_FILE);
const program = (version: string) =>
platform === "darwin" ? "runtime/service/t3" : `versions/${version}/t3`;
const envBytes = new TextEncoder().encode(
'# user configuration\r\nT3CODE_TOKEN="literal $HOME # token"',
);
Expand All @@ -928,7 +930,7 @@ it.layer(NodeServices.layer)("boot service install", (it) => {
protocol: SERVICE_LAUNCHER_PROTOCOL,
activeVersion: "1.2.4",
});
expect(yield* fs.readFileString(plan.unitPath)).toContain("versions/1.2.4/t3");
expect(yield* fs.readFileString(plan.unitPath)).toContain(program("1.2.4"));
expect(
commands.filter(
(command) =>
Expand All @@ -950,7 +952,7 @@ it.layer(NodeServices.layer)("boot service install", (it) => {
protocol: SERVICE_LAUNCHER_PROTOCOL,
activeVersion: "1.2.5",
});
expect(yield* fs.readFileString(plan.unitPath)).toContain("versions/1.2.5/t3");
expect(yield* fs.readFileString(plan.unitPath)).toContain(program("1.2.5"));
expect((yield* latest.status).current).toBe(true);
expect(
yield* fs.exists(path.join(path.dirname(statePath), SERVICE_RESTART_PENDING_FILE)),
Expand Down Expand Up @@ -1405,8 +1407,10 @@ process.disconnect();

it.effect("installs, reports current state, and uninstalls on macOS", () =>
Effect.gen(function* () {
const { service, fs, statePath, commands, timeouts, runtime } = yield* makeHarness("darwin");
const { service, makeService, fs, baseDir, statePath, commands, timeouts } =
yield* makeHarness("darwin");
const path = yield* Path.Path;
const launcherDir = path.join(baseDir, "runtime", "service");
const plan = yield* service.install();

expect(
Expand All @@ -1422,13 +1426,19 @@ process.disconnect();
activeVersion: "1.2.3",
});
expect(yield* fs.readFileString(plan.unitPath)).toContain(
` <string>${runtime.entryPath}</string>\n <string>__service-launcher</string>`,
` <string>${path.join(launcherDir, "t3")}</string>\n <string>__service-launcher</string>`,
);
expect(yield* service.status).toMatchObject({
current: true,
installedVersion: "1.2.3",
});
expect(yield* service.uninstall).toBe(true);
yield* fs.writeFileString(path.join(launcherDir, ".install-complete"), "1.2.2\n");
expect((yield* service.status).current).toBe(false);
yield* service.install();
expect((yield* service.status).current).toBe(true);
const otherHome = yield* makeService(undefined, "1.2.3", path.join(baseDir, "other-home"));
expect(yield* otherHome.uninstall).toBe(true);
expect(yield* fs.exists(launcherDir)).toBe(false);
expect((yield* service.status).installed).toBe(false);
expect(commands.some((command) => command.startsWith("systemctl "))).toBe(false);
// launchd can take 90s to bootout; the runner's 60s default could let bootstrap race it.
Expand Down Expand Up @@ -1552,6 +1562,30 @@ process.disconnect();
}),
);

it.effect("swaps in a new launch agent runtime without writing into the running one", () =>
Effect.gen(function* () {
const { service, makeService, fs, baseDir } = yield* makeHarness("darwin");
const path = yield* Path.Path;
const launcherDir = path.join(baseDir, "runtime", "service");
const versionEntry = (version: string) =>
path.join(baseDir, "runtime", "versions", version, "t3");
yield* service.install();
const running = yield* fs.open(path.join(launcherDir, "t3"), { flag: "r" });

const newer = yield* makeService(undefined, "1.2.4");
yield* fs.writeFileString(versionEntry("1.2.4"), "#!/bin/sh\n# 1.2.4\n");
yield* newer.install({ start: false });

const buffer = new Uint8Array(64);
const read = yield* running.read(buffer);
expect(new TextDecoder().decode(buffer.subarray(0, Number(read)))).toBe("#!/bin/sh\n");
expect(yield* fs.readFileString(path.join(launcherDir, "t3"))).toBe("#!/bin/sh\n# 1.2.4\n");
expect((yield* fs.stat(path.join(launcherDir, "t3"))).mode & 0o111).toBe(
(yield* fs.stat(versionEntry("1.2.4"))).mode & 0o111,
);
}),
);

// regression: #8173 keep provider CLIs available in the macOS service
it.effect("reconstructs a launch agent search path when the installer has no PATH", () =>
Effect.gen(function* () {
Expand Down
57 changes: 43 additions & 14 deletions apps/server/src/cloud/bootService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import { CLI_RELEASE_BASE_URL_ENV } from "@t3tools/shared/cliRelease";

import * as ProcessRunner from "../processRunner.ts";
import { acquireServiceSelectionLock } from "./serviceSelectionLock.ts";
import { publishStableLauncher, stableLauncherPaths } from "./stableLauncher.ts";
import {
serviceLauncherIsRunning,
serviceLauncherLockPath,
Expand All @@ -39,6 +40,7 @@ import {
SERVICE_UNIT_ENV,
compareExactServiceVersions,
disabledServiceTask,
escapeXmlText,
serviceTaskIsDisabled,
serviceUnitBaseDirOf as bootServiceBaseDirOf,
serviceUnitEnvironmentValueOf,
Expand Down Expand Up @@ -144,10 +146,6 @@ export function renderBootServiceUnit(plan: BootServicePlan): string {
].join("\n");
}

function escapeXmlText(value: string): string {
return value.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;");
}

/** Launch agents cannot rely on the user's shell or PATH. */
export function renderBootServicePlist(
plan: BootServicePlan,
Expand Down Expand Up @@ -966,6 +964,8 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: {
});
});
const logPath = path.join(input.logsDir, BOOT_SERVICE_LOG_FILE);
const launcherPathsFor = (manager: BootServiceManager | undefined) =>
manager?.kind === "launchd" ? stableLauncherPaths(input.baseDir) : runtimePaths;
const resolveTarget = (systemdUnitDir: string | undefined) => {
const manager = selectBootServiceManager({
platform,
Expand All @@ -990,7 +990,7 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: {
});
const unitPath = manager?.unitPath ?? "";
const plan: BootServicePlan = {
program: [runtimePaths.entryPath, "__service-launcher"],
program: [launcherPathsFor(manager).entryPath, "__service-launcher"],
baseDir: input.baseDir,
logPath,
unitPath,
Expand Down Expand Up @@ -1391,6 +1391,13 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: {
return yield* new BootServiceUpdatePendingError();
}
}
if (manager.kind === "launchd") {
yield* Effect.tryPromise({
try: () =>
publishStableLauncher(input.baseDir, runtimePaths.entryPath, input.cliVersion),
catch: (cause) => new BootServiceInstallError({ cause }),
}).pipe(Effect.uninterruptible);
}
const unit = manager.render(plan);
yield* writeDurably(unitPath, unit);
if (!start && manager.kind === "schtasks") yield* refreshWindowsTask(unit);
Expand Down Expand Up @@ -1524,9 +1531,22 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: {
)
return false;
yield* runSteps(manager.deactivate);
yield* fs
.remove(unitPath)
.pipe(Effect.mapError((cause) => new BootServiceInstallError({ cause })));
yield* withSelectionLock(
unitPath,
fs.readFileString(unitPath).pipe(
Effect.tap(() => fs.remove(unitPath)),
Effect.flatMap((unit) =>
manager.kind === "launchd"
? fs.remove(
stableLauncherPaths(bootServiceBaseDirOf(unit) ?? input.baseDir).directory,
{ recursive: true, force: true },
)
: Effect.void,
),
Effect.mapError((cause) => new BootServiceInstallError({ cause })),
Effect.uninterruptible,
),
);
yield* runSteps(manager.finalize);
return true;
}).pipe(Effect.withSpan("cloud.boot_service.uninstall"));
Expand All @@ -1539,10 +1559,20 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: {
if (!(yield* fs.exists(unitPath))) {
return { supported: true, installed: false, current: false, unitPath, logPath };
}
const [unit, runtimeEntryExists, runtimeSentinel, stateText] = yield* Effect.all([
const isCurrent = (paths: { readonly entryPath: string; readonly sentinelPath: string }) =>
Effect.all([
fs.exists(paths.entryPath),
fs.readFileString(paths.sentinelPath).pipe(Effect.option),
]).pipe(
Effect.map(
([entryExists, sentinel]) =>
entryExists && Option.isSome(sentinel) && sentinel.value.trim() === input.cliVersion,
),
);
const [unit, runtimeIsCurrent, launcherIsCurrent, stateText] = yield* Effect.all([
fs.readFileString(unitPath),
fs.exists(runtimePaths.entryPath),
fs.readFileString(runtimePaths.sentinelPath).pipe(Effect.option),
isCurrent(runtimePaths),
isCurrent(launcherPathsFor(manager)),
fs.readFileString(statePath).pipe(Effect.option),
]);
const state = Option.isSome(stateText) ? parseServiceState(stateText.value) : undefined;
Expand Down Expand Up @@ -1573,9 +1603,8 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: {
problems.length === 0 &&
!(manager.kind === "systemd" && systemdUnsafeValue(planForUnit(unit))) &&
normalizeUnit(unit) === normalizeUnit(manager.render(planForUnit(unit))) &&
runtimeEntryExists &&
Option.isSome(runtimeSentinel) &&
runtimeSentinel.value.trim() === input.cliVersion &&
runtimeIsCurrent &&
launcherIsCurrent &&
state?.activeVersion === input.cliVersion &&
state?.update?.status !== "pending",
unitPath,
Expand Down
3 changes: 3 additions & 0 deletions apps/server/src/cloud/serviceProtocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,9 @@ const unquoteSystemdValue = (raw: string) =>
: raw
).replaceAll("%%", "%");

export const escapeXmlText = (value: string) =>
value.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;");

const unescapeXmlText = (value: string) =>
value.replaceAll("&lt;", "<").replaceAll("&gt;", ">").replaceAll("&amp;", "&");

Expand Down
68 changes: 68 additions & 0 deletions apps/server/src/cloud/stableLauncher.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
// @effect-diagnostics nodeBuiltinImport:off -- Shared with the standalone launcher, which has no Effect runtime.
import * as NodeChildProcess from "node:child_process";
import * as NodeFSP from "node:fs/promises";
import * as NodePath from "node:path";

/**
* macOS TCC keys a launch agent's privacy grants to its program path, so launchd starts a copy
* of the selected runtime's executable kept here. `__service-launcher` loads only Node built-ins,
* so the executable alone is the launcher.
*/
export const stableLauncherPaths = (baseDir: string) => {
const directory = NodePath.join(baseDir, "runtime", "service");
return {
directory,
entryPath: NodePath.join(directory, "t3"),
sentinelPath: NodePath.join(directory, ".install-complete"),
};
};

// /bin/cp -c clones on APFS; Node's copyFile copies every byte on macOS.
const cloneFile = (source: string, destination: string) =>
new Promise<boolean>((resolve) => {
NodeChildProcess.execFile(
"/bin/cp",
["-c", "-p", source, destination],
{ timeout: 60_000 },
(error) => resolve(error === null),
);
});

/**
* Callers hold the unit's selection lease. Each file is renamed over its predecessor, so the
* program path never goes missing and a running launcher keeps its old inode.
*/
export async function publishStableLauncher(
baseDir: string,
sourceEntryPath: string,
version: string,
): Promise<void> {
const paths = stableLauncherPaths(baseDir);
const stagedEntry = NodePath.join(paths.directory, ".t3.staged");
const stagedSentinel = NodePath.join(paths.directory, ".install-complete.staged");
await NodeFSP.mkdir(paths.directory, { recursive: true });
try {
await NodeFSP.rm(stagedEntry, { force: true });
if (!(await cloneFile(sourceEntryPath, stagedEntry))) {
await NodeFSP.copyFile(sourceEntryPath, stagedEntry);
}
await NodeFSP.writeFile(stagedSentinel, `${version}\n`);
await syncPath(stagedEntry);
await syncPath(stagedSentinel);
await NodeFSP.rename(stagedEntry, paths.entryPath);
await NodeFSP.rename(stagedSentinel, paths.sentinelPath);
await syncPath(paths.directory);
} finally {
await NodeFSP.rm(stagedEntry, { force: true });
await NodeFSP.rm(stagedSentinel, { force: true });
}
}

async function syncPath(filePath: string): Promise<void> {
const handle = await NodeFSP.open(filePath, "r");
try {
await handle.sync();
} finally {
await handle.close();
}
}
Loading
Loading