// @effect-diagnostics nodeBuiltinImport:off// node:child_process directly: the foreground-server replacement must be a// detached fire-and-forget child that outlives this process, while Effect's// ChildProcessSpawner ties every child to a scope that kills it.import { ServerSelfUpdateError, type ServerSelfUpdateCapability, type ServerSelfUpdateInput, type ServerSelfUpdateResult,} from "@t3tools/contracts";import { HostProcessArguments, HostProcessEnvironment, HostProcessExecutablePath, HostProcessPlatform,} from "@t3tools/shared/hostProcess";import * as NodeChildProcess from "node:child_process";import * as Context from "effect/Context";import * as Duration from "effect/Duration";import * as Effect from "effect/Effect";import * as FileSystem from "effect/FileSystem";import * as Layer from "effect/Layer";import * as Path from "effect/Path";import * as Ref from "effect/Ref"; import * as ServerConfig from "../config.ts";import { writeFileStringAtomically } from "../atomicWrite.ts";import * as ProcessRunner from "../processRunner.ts";import { BOOT_SERVICE_UNIT_ENV, BOOT_SERVICE_UNIT_FILE, quoteSystemdValue, renderBootServiceUnit,} from "./bootService.ts";import { ensurePinnedRuntimeInstalled, removePinnedRuntimeInstallation } from "./pinnedRuntime.ts"; /** * Lets a connected client replace this server with another published `t3` * version over RPC — the only update path that works when the user is not at * the machine (phone against a home server, relay-managed box). The target * version is npm-installed into the pinned runtime and verified before * anything restarts, so a failed install leaves the running server untouched. */ const PREFLIGHT_TIMEOUT = Duration.seconds(30);/** Grace between acknowledging the RPC and killing the process, so the response (and its relay hop) flushes before the socket drops. */const RESTART_DELAY = Duration.seconds(2); /** Exact npm versions only — never dist-tags — so the acknowledgement names the version that was actually installed. Also keeps the value safe to pass to npm and embed in filesystem paths. */const EXACT_VERSION_PATTERN = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/; export interface ServerSelfUpdateHost { readonly execPath: string; readonly cliEntryPath: string; /** Original CLI arguments after the entry path, replayed on respawn. */ readonly cliArgs: ReadonlyArray<string>; /** Resolves once the foreground replacement process has actually spawned. */ readonly spawnDetached: ( command: string, args: ReadonlyArray<string>, ) => Effect.Effect<void, ProcessRunner.ProcessSpawnError>; readonly exitProcess: () => void;} function normalizeEntryPath(entryPath: string): string { return entryPath.replaceAll("\\", "/");} /** * Only a published npm artifact can be swapped for another version: dev * checkouts (apps/server/dist) and the desktop app's bundled backend have no * npm identity, and the desktop manages its own updates. */export function isPublishedCliEntry(entryPath: string): boolean { return normalizeEntryPath(entryPath).includes("/node_modules/t3/dist/");} /** * The update path this process can offer, or null when only a manual * relaunch works. "desktop-managed" — the T3 Code desktop app spawned this * backend and owns its version; only updating the app updates it. * "boot-service" — this is the systemd-supervised process from * bootService.ts: rewrite the unit and let systemd swap it. "respawn" — a * foreground POSIX process running a published artifact: replace it with a * detached child. Windows foreground runs are unsupported for now (no * equivalent of the detach-and-exec handoff below). */export const resolveServerSelfUpdateCapability = Effect.fn( "cloud.server_self_update.resolve_capability",)(function* (input: { /** True when the desktop app supervises this backend (mode "desktop"). */ readonly desktopManaged: boolean;}) { if (input.desktopManaged) { return "desktop-managed" as const; } const platform = yield* HostProcessPlatform; const env = yield* HostProcessEnvironment; const hostArguments = yield* HostProcessArguments; const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; const entryPath = hostArguments[1] ?? ""; if (entryPath === "") { return null; } const homeDir = env.HOME ?? ""; if (platform === "linux" && homeDir !== "") { const unitPath = path.join(homeDir, ".config", "systemd", "user", BOOT_SERVICE_UNIT_FILE); const unitReferencesEntry = yield* fs.readFileString(unitPath).pipe( Effect.map((unit) => unit.includes(quoteSystemdValue(entryPath))), Effect.orElseSucceed(() => false), ); // INVOCATION_ID only proves that some systemd unit launched us. The // explicit marker written into t3code.service identifies this unit as the // supervisor that will replace the current process when restarted. if ( unitReferencesEntry && (env.INVOCATION_ID ?? "") !== "" && env[BOOT_SERVICE_UNIT_ENV] === BOOT_SERVICE_UNIT_FILE ) { return "boot-service" as const; } // A process owned by another (or a legacy unmarked) systemd unit must not // use the foreground respawn path: Restart=always could otherwise launch // the old unit beside the detached replacement. if ((env.INVOCATION_ID ?? "") !== "") { return null; } } if ((platform === "linux" || platform === "darwin") && isPublishedCliEntry(entryPath)) { return "respawn" as const; } return null;}); export class ServerSelfUpdate extends Context.Service< ServerSelfUpdate, { readonly update: ( input: ServerSelfUpdateInput, ) => Effect.Effect<ServerSelfUpdateResult, ServerSelfUpdateError>; }>()("t3/cloud/selfUpdate/ServerSelfUpdate") {} export const make = Effect.fn("cloud.server_self_update.make")(function* (options?: { readonly host?: Partial<ServerSelfUpdateHost>;}) { const serverConfig = yield* ServerConfig.ServerConfig; const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; const runner = yield* ProcessRunner.ProcessRunner; const env = yield* HostProcessEnvironment; const hostExecPath = yield* HostProcessExecutablePath; const hostArguments = yield* HostProcessArguments; const capability: ServerSelfUpdateCapability | null = yield* resolveServerSelfUpdateCapability({ desktopManaged: serverConfig.mode === "desktop", }); const host: ServerSelfUpdateHost = { execPath: options?.host?.execPath ?? hostExecPath, cliEntryPath: options?.host?.cliEntryPath ?? hostArguments[1] ?? "", cliArgs: options?.host?.cliArgs ?? hostArguments.slice(2), spawnDetached: options?.host?.spawnDetached ?? ((command, args) => Effect.callback<void, ProcessRunner.ProcessSpawnError>((resume) => { const spawnError = (cause: unknown) => new ProcessRunner.ProcessSpawnError({ command, argumentCount: args.length, cause, }); let child: NodeChildProcess.ChildProcess; try { child = NodeChildProcess.spawn(command, [...args], { detached: true, stdio: "ignore", }); } catch (cause) { resume(Effect.fail(spawnError(cause))); return; } const onSpawnError = (cause: Error) => resume(Effect.fail(spawnError(cause))); child.once("error", onSpawnError); child.once("spawn", () => { child.removeListener("error", onSpawnError); // Keep asynchronous child errors from becoming uncaught after the // successful spawn handoff has already been acknowledged. child.on("error", () => undefined); child.unref(); resume(Effect.void); }); })), exitProcess: options?.host?.exitProcess ?? (() => process.exit(0)), }; const inFlight = yield* Ref.make(false); const failWith = (reason: string, cause?: unknown) => cause === undefined ? new ServerSelfUpdateError({ reason }) : new ServerSelfUpdateError({ reason, cause }); /** Deferred so the RPC acknowledgement flushes before the process dies. Detached from the request scope: the triggering connection is exactly what the restart tears down. */ const scheduleRestart = (restart: Effect.Effect<void>) => Effect.sleep(RESTART_DELAY).pipe( Effect.andThen(restart), Effect.forkDetach({ startImmediately: true }), ); const writeUnitAtomically = (filePath: string, contents: string) => writeFileStringAtomically({ filePath, contents }).pipe( Effect.provideService(FileSystem.FileSystem, fs), Effect.provideService(Path.Path, path), ); const update: ServerSelfUpdate["Service"]["update"] = Effect.fn( "cloud.server_self_update.update", )(function* (input) { if (capability === "desktop-managed") { return yield* failWith( "This server is managed by the T3 Code desktop app on its machine; update the desktop app to update it.", ); } if (capability === null) { return yield* failWith( "This server cannot update itself; relaunch it manually with the new version.", ); } const activeMethod = capability; const targetVersion = input.targetVersion.trim(); if (!EXACT_VERSION_PATTERN.test(targetVersion)) { return yield* failWith(`'${targetVersion}' is not an exact t3 version.`); } const alreadyRunning = yield* Ref.getAndSet(inFlight, true); if (alreadyRunning) { return yield* failWith("A server update is already in progress."); } return yield* Effect.gen(function* () { const runtimePaths = yield* ensurePinnedRuntimeInstalled({ baseDir: serverConfig.baseDir, version: targetVersion, fs, path, runner, }).pipe( Effect.mapError((error) => failWith("Could not install the requested t3 version.", error)), ); // A broken artifact (failed native build, incompatible node) must be // caught while the current server is still alive to report it. const preflight = yield* runner .run({ command: host.execPath, args: [runtimePaths.entryPath, "--version"], timeout: PREFLIGHT_TIMEOUT, }) .pipe( Effect.mapError((cause) => failWith(`Could not verify the installed t3@${targetVersion}.`, cause), ), ); // Effect CLI's unstable formatVersion currently emits `${name} v${version}`. // Extract the version token so surrounding presentation changes do not break updates. const reportedVersion = /\bv(\S+)\s*$/.exec(preflight.stdout)?.[1]; if (preflight.code !== 0 || reportedVersion !== targetVersion) { // A completed npm install can still be unusable under this Node or on // this machine. Remove its sentinel and tree so a retry of the same // version performs a clean install instead of reusing a known-bad one. yield* removePinnedRuntimeInstallation({ baseDir: serverConfig.baseDir, version: targetVersion, fs, path, }).pipe( Effect.mapError((error) => failWith(`Could not remove the failed t3@${targetVersion} installation.`, error), ), ); return yield* failWith( preflight.code !== 0 ? `The installed t3@${targetVersion} failed its version check (exit code ${String(preflight.code)}).` : `The installed runtime did not report the requested t3@${targetVersion} version.`, ); } if (activeMethod === "boot-service") { const homeDir = env.HOME ?? ""; const unitPath = path.join(homeDir, ".config", "systemd", "user", BOOT_SERVICE_UNIT_FILE); const previousUnit = yield* fs .readFileString(unitPath) .pipe( Effect.mapError((cause) => failWith("Could not read the current systemd unit.", cause)), ); // Same shape bootService.install writes, so host lifecycle commands // still recognize the unit as current. const unit = renderBootServiceUnit({ nodePath: host.execPath, t3EntryPath: runtimePaths.entryPath, baseDir: serverConfig.baseDir, logPath: path.join(serverConfig.logsDir, "boot-service.log"), unitPath, }); yield* writeUnitAtomically(unitPath, unit).pipe( Effect.mapError((cause) => failWith("Could not update the systemd unit.", cause)), ); const reloadSystemd = Effect.fn("cloud.server_self_update.reload_systemd")(function* () { const reload = yield* runner .run({ command: "systemctl", args: ["--user", "daemon-reload"] }) .pipe(Effect.mapError((cause) => failWith("Could not reload systemd units.", cause))); if (reload.code !== 0) { return yield* failWith( `Reloading systemd units failed (exit code ${String(reload.code)}).`, ); } }); yield* reloadSystemd().pipe( Effect.catch((reloadError) => writeUnitAtomically(unitPath, previousUnit).pipe( Effect.mapError((rollbackCause) => failWith("Could not restore the previous systemd unit.", { reloadError, rollbackCause, }), ), // Systemd should still have the old unit in memory after the // failed reload, but retry after restoring in case it applied a // partial update before returning an error. Effect.andThen(reloadSystemd().pipe(Effect.ignore)), Effect.andThen(Effect.fail(reloadError)), ), ), ); yield* Effect.logInfo("Server self-update installed; restarting boot service.", { targetVersion, }); // A successful systemd restart stops this process, so the RPC is // interrupted and the reconnecting client observes the new version. // A rejected restart returns while the old process is still alive; // restore the previous unit and report that failure through the RPC. yield* Effect.gen(function* () { const restart = yield* runner .run({ command: "systemctl", args: ["--user", "restart", BOOT_SERVICE_UNIT_FILE], }) .pipe( Effect.mapError((cause) => failWith("Could not restart the systemd boot service.", cause), ), ); if (restart.code !== 0) { return yield* failWith( `Restarting the systemd boot service failed (exit code ${String(restart.code)}).`, ); } }).pipe( Effect.catch((restartError) => writeUnitAtomically(unitPath, previousUnit).pipe( Effect.andThen(reloadSystemd()), Effect.mapError((rollbackError) => failWith("Could not restore the previous systemd unit.", { restartError, rollbackError, }), ), Effect.andThen(Effect.fail(restartError)), ), ), ); } else { // Spawn the shim before acknowledging the RPC so ENOENT/EACCES and // other launch failures leave this server alive and return a useful // error. The shim itself waits until after the acknowledgement and // deferred exit before binding the replacement server. yield* host .spawnDetached("/bin/sh", [ "-c", 'sleep 3; exec "$@"', "t3-self-update", host.execPath, runtimePaths.entryPath, ...host.cliArgs, ]) .pipe( Effect.mapError((cause) => failWith("Could not start the replacement t3 process.", cause), ), ); yield* Effect.logInfo("Server self-update installed; respawning.", { targetVersion }); yield* scheduleRestart( Effect.try({ try: () => host.exitProcess(), catch: (cause) => failWith("Could not exit the replaced t3 process.", cause), }).pipe( Effect.catch((error) => Effect.logError("Server self-update could not exit the replaced process.").pipe( Effect.annotateLogs({ targetVersion, error: error.reason }), Effect.ensuring(Ref.set(inFlight, false)), ), ), ), ); } return { targetVersion, method: activeMethod }; }).pipe(Effect.onError(() => Ref.set(inFlight, false))); }); return ServerSelfUpdate.of({ update });}); export const layer = Layer.effect(ServerSelfUpdate, make()).pipe( Layer.provide(ProcessRunner.layer),);
// @effect-diagnostics nodeBuiltinImport:off
// node:child_process directly: the foreground-server replacement must be a
// detached fire-and-forget child that outlives this process, while Effect's
// ChildProcessSpawner ties every child to a scope that kills it.
import {
ServerSelfUpdateError,
type ServerSelfUpdateCapability,
type ServerSelfUpdateInput,
type ServerSelfUpdateResult,
} from "@t3tools/contracts";
import {
HostProcessArguments,
HostProcessEnvironment,
HostProcessExecutablePath,
HostProcessPlatform,
} from "@t3tools/shared/hostProcess";
import * as NodeChildProcess from "node:child_process";
import * as Context from "effect/Context";
import * as Duration from "effect/Duration";
import * as Effect from "effect/Effect";
import * as FileSystem from "effect/FileSystem";
import * as Layer from "effect/Layer";
import * as Path from "effect/Path";
import * as Ref from "effect/Ref";
import * as ServerConfig from "../config.ts";
import { writeFileStringAtomically } from "../atomicWrite.ts";
import * as ProcessRunner from "../processRunner.ts";
import {
BOOT_SERVICE_UNIT_ENV,
BOOT_SERVICE_UNIT_FILE,
quoteSystemdValue,
renderBootServiceUnit,
} from "./bootService.ts";
import { ensurePinnedRuntimeInstalled, removePinnedRuntimeInstallation } from "./pinnedRuntime.ts";
/**
* Lets a connected client replace this server with another published `t3`
* version over RPC — the only update path that works when the user is not at
* the machine (phone against a home server, relay-managed box). The target
* version is npm-installed into the pinned runtime and verified before
* anything restarts, so a failed install leaves the running server untouched.
*/
const PREFLIGHT_TIMEOUT = Duration.seconds(30);
/** Grace between acknowledging the RPC and killing the process, so the
response (and its relay hop) flushes before the socket drops. */
const RESTART_DELAY = Duration.seconds(2);
/** Exact npm versions only — never dist-tags — so the acknowledgement names
the version that was actually installed. Also keeps the value safe to
pass to npm and embed in filesystem paths. */
const EXACT_VERSION_PATTERN = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/;
export interface ServerSelfUpdateHost {
readonly execPath: string;
readonly cliEntryPath: string;
/** Original CLI arguments after the entry path, replayed on respawn. */
readonly cliArgs: ReadonlyArray<string>;
/** Resolves once the foreground replacement process has actually spawned. */
readonly spawnDetached: (
command: string,
args: ReadonlyArray<string>,
) => Effect.Effect<void, ProcessRunner.ProcessSpawnError>;
readonly exitProcess: () => void;
}
function normalizeEntryPath(entryPath: string): string {
return entryPath.replaceAll("\\", "/");
}
/**
* Only a published npm artifact can be swapped for another version: dev
* checkouts (apps/server/dist) and the desktop app's bundled backend have no
* npm identity, and the desktop manages its own updates.
*/
export function isPublishedCliEntry(entryPath: string): boolean {
return normalizeEntryPath(entryPath).includes("/node_modules/t3/dist/");
}
/**
* The update path this process can offer, or null when only a manual
* relaunch works. "desktop-managed" — the T3 Code desktop app spawned this
* backend and owns its version; only updating the app updates it.
* "boot-service" — this is the systemd-supervised process from
* bootService.ts: rewrite the unit and let systemd swap it. "respawn" — a
* foreground POSIX process running a published artifact: replace it with a
* detached child. Windows foreground runs are unsupported for now (no
* equivalent of the detach-and-exec handoff below).
*/
export const resolveServerSelfUpdateCapability = Effect.fn(
"cloud.server_self_update.resolve_capability",
)(function* (input: {
/** True when the desktop app supervises this backend (mode "desktop"). */
readonly desktopManaged: boolean;
}) {
if (input.desktopManaged) {
return "desktop-managed" as const;
}
const platform = yield* HostProcessPlatform;
const env = yield* HostProcessEnvironment;
const hostArguments = yield* HostProcessArguments;
const fs = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const entryPath = hostArguments[1] ?? "";
if (entryPath === "") {
return null;
}
const homeDir = env.HOME ?? "";
if (platform === "linux" && homeDir !== "") {
const unitPath = path.join(homeDir, ".config", "systemd", "user", BOOT_SERVICE_UNIT_FILE);
const unitReferencesEntry = yield* fs.readFileString(unitPath).pipe(
Effect.map((unit) => unit.includes(quoteSystemdValue(entryPath))),
Effect.orElseSucceed(() => false),
);
// INVOCATION_ID only proves that some systemd unit launched us. The
// explicit marker written into t3code.service identifies this unit as the
// supervisor that will replace the current process when restarted.
if (
unitReferencesEntry &&
(env.INVOCATION_ID ?? "") !== "" &&
env[BOOT_SERVICE_UNIT_ENV] === BOOT_SERVICE_UNIT_FILE
) {
return "boot-service" as const;
}
// A process owned by another (or a legacy unmarked) systemd unit must not
// use the foreground respawn path: Restart=always could otherwise launch
// the old unit beside the detached replacement.
if ((env.INVOCATION_ID ?? "") !== "") {
return null;
}
}
if ((platform === "linux" || platform === "darwin") && isPublishedCliEntry(entryPath)) {
return "respawn" as const;
}
return null;
});
export class ServerSelfUpdate extends Context.Service<
ServerSelfUpdate,
{
readonly update: (
input: ServerSelfUpdateInput,
) => Effect.Effect<ServerSelfUpdateResult, ServerSelfUpdateError>;
}
>()("t3/cloud/selfUpdate/ServerSelfUpdate") {}
export const make = Effect.fn("cloud.server_self_update.make")(function* (options?: {
readonly host?: Partial<ServerSelfUpdateHost>;
}) {
const serverConfig = yield* ServerConfig.ServerConfig;
const fs = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const runner = yield* ProcessRunner.ProcessRunner;
const env = yield* HostProcessEnvironment;
const hostExecPath = yield* HostProcessExecutablePath;
const hostArguments = yield* HostProcessArguments;
const capability: ServerSelfUpdateCapability | null = yield* resolveServerSelfUpdateCapability({
desktopManaged: serverConfig.mode === "desktop",
});
const host: ServerSelfUpdateHost = {
execPath: options?.host?.execPath ?? hostExecPath,
cliEntryPath: options?.host?.cliEntryPath ?? hostArguments[1] ?? "",
cliArgs: options?.host?.cliArgs ?? hostArguments.slice(2),
spawnDetached:
options?.host?.spawnDetached ??
((command, args) =>
Effect.callback<void, ProcessRunner.ProcessSpawnError>((resume) => {
const spawnError = (cause: unknown) =>
new ProcessRunner.ProcessSpawnError({
command,
argumentCount: args.length,
cause,
});
let child: NodeChildProcess.ChildProcess;
try {
child = NodeChildProcess.spawn(command, [...args], {
detached: true,
stdio: "ignore",
});
} catch (cause) {
resume(Effect.fail(spawnError(cause)));
return;
}
const onSpawnError = (cause: Error) => resume(Effect.fail(spawnError(cause)));
child.once("error", onSpawnError);
child.once("spawn", () => {
child.removeListener("error", onSpawnError);
// Keep asynchronous child errors from becoming uncaught after the
// successful spawn handoff has already been acknowledged.
child.on("error", () => undefined);
child.unref();
resume(Effect.void);
});
})),
exitProcess: options?.host?.exitProcess ?? (() => process.exit(0)),
};
const inFlight = yield* Ref.make(false);
const failWith = (reason: string, cause?: unknown) =>
cause === undefined
? new ServerSelfUpdateError({ reason })
: new ServerSelfUpdateError({ reason, cause });
/** Deferred so the RPC acknowledgement flushes before the process dies.
Detached from the request scope: the triggering connection is exactly
what the restart tears down. */
const scheduleRestart = (restart: Effect.Effect<void>) =>
Effect.sleep(RESTART_DELAY).pipe(
Effect.andThen(restart),
Effect.forkDetach({ startImmediately: true }),
);
const writeUnitAtomically = (filePath: string, contents: string) =>
writeFileStringAtomically({ filePath, contents }).pipe(
Effect.provideService(FileSystem.FileSystem, fs),
Effect.provideService(Path.Path, path),
);
const update: ServerSelfUpdate["Service"]["update"] = Effect.fn(
"cloud.server_self_update.update",
)(function* (input) {
if (capability === "desktop-managed") {
return yield* failWith(
"This server is managed by the T3 Code desktop app on its machine; update the desktop app to update it.",
);
}
if (capability === null) {
return yield* failWith(
"This server cannot update itself; relaunch it manually with the new version.",
);
}
const activeMethod = capability;
const targetVersion = input.targetVersion.trim();
if (!EXACT_VERSION_PATTERN.test(targetVersion)) {
return yield* failWith(`'${targetVersion}' is not an exact t3 version.`);
}
const alreadyRunning = yield* Ref.getAndSet(inFlight, true);
if (alreadyRunning) {
return yield* failWith("A server update is already in progress.");
}
return yield* Effect.gen(function* () {
const runtimePaths = yield* ensurePinnedRuntimeInstalled({
baseDir: serverConfig.baseDir,
version: targetVersion,
fs,
path,
runner,
}).pipe(
Effect.mapError((error) => failWith("Could not install the requested t3 version.", error)),
);
// A broken artifact (failed native build, incompatible node) must be
// caught while the current server is still alive to report it.
const preflight = yield* runner
.run({
command: host.execPath,
args: [runtimePaths.entryPath, "--version"],
timeout: PREFLIGHT_TIMEOUT,
})
.pipe(
Effect.mapError((cause) =>
failWith(`Could not verify the installed t3@${targetVersion}.`, cause),
),
);
// Effect CLI's unstable formatVersion currently emits `${name} v${version}`.
// Extract the version token so surrounding presentation changes do not break updates.
const reportedVersion = /\bv(\S+)\s*$/.exec(preflight.stdout)?.[1];
if (preflight.code !== 0 || reportedVersion !== targetVersion) {
// A completed npm install can still be unusable under this Node or on
// this machine. Remove its sentinel and tree so a retry of the same
// version performs a clean install instead of reusing a known-bad one.
yield* removePinnedRuntimeInstallation({
baseDir: serverConfig.baseDir,
version: targetVersion,
fs,
path,
}).pipe(
Effect.mapError((error) =>
failWith(`Could not remove the failed t3@${targetVersion} installation.`, error),
),
);
return yield* failWith(
preflight.code !== 0
? `The installed t3@${targetVersion} failed its version check (exit code ${String(preflight.code)}).`
: `The installed runtime did not report the requested t3@${targetVersion} version.`,
);
}
if (activeMethod === "boot-service") {
const homeDir = env.HOME ?? "";
const unitPath = path.join(homeDir, ".config", "systemd", "user", BOOT_SERVICE_UNIT_FILE);
const previousUnit = yield* fs
.readFileString(unitPath)
.pipe(
Effect.mapError((cause) => failWith("Could not read the current systemd unit.", cause)),
);
// Same shape bootService.install writes, so host lifecycle commands
// still recognize the unit as current.
const unit = renderBootServiceUnit({
nodePath: host.execPath,
t3EntryPath: runtimePaths.entryPath,
baseDir: serverConfig.baseDir,
logPath: path.join(serverConfig.logsDir, "boot-service.log"),
unitPath,
});
yield* writeUnitAtomically(unitPath, unit).pipe(
Effect.mapError((cause) => failWith("Could not update the systemd unit.", cause)),
);
const reloadSystemd = Effect.fn("cloud.server_self_update.reload_systemd")(function* () {
const reload = yield* runner
.run({ command: "systemctl", args: ["--user", "daemon-reload"] })
.pipe(Effect.mapError((cause) => failWith("Could not reload systemd units.", cause)));
if (reload.code !== 0) {
return yield* failWith(
`Reloading systemd units failed (exit code ${String(reload.code)}).`,
);
}
});
yield* reloadSystemd().pipe(
Effect.catch((reloadError) =>
writeUnitAtomically(unitPath, previousUnit).pipe(
Effect.mapError((rollbackCause) =>
failWith("Could not restore the previous systemd unit.", {
reloadError,
rollbackCause,
}),
),
// Systemd should still have the old unit in memory after the
// failed reload, but retry after restoring in case it applied a
// partial update before returning an error.
Effect.andThen(reloadSystemd().pipe(Effect.ignore)),
Effect.andThen(Effect.fail(reloadError)),
),
),
);
yield* Effect.logInfo("Server self-update installed; restarting boot service.", {
targetVersion,
});
// A successful systemd restart stops this process, so the RPC is
// interrupted and the reconnecting client observes the new version.
// A rejected restart returns while the old process is still alive;
// restore the previous unit and report that failure through the RPC.
yield* Effect.gen(function* () {
const restart = yield* runner
.run({
command: "systemctl",
args: ["--user", "restart", BOOT_SERVICE_UNIT_FILE],
})
.pipe(
Effect.mapError((cause) =>
failWith("Could not restart the systemd boot service.", cause),
),
);
if (restart.code !== 0) {
return yield* failWith(
`Restarting the systemd boot service failed (exit code ${String(restart.code)}).`,
);
}
}).pipe(
Effect.catch((restartError) =>
writeUnitAtomically(unitPath, previousUnit).pipe(
Effect.andThen(reloadSystemd()),
Effect.mapError((rollbackError) =>
failWith("Could not restore the previous systemd unit.", {
restartError,
rollbackError,
}),
),
Effect.andThen(Effect.fail(restartError)),
),
),
);
} else {
// Spawn the shim before acknowledging the RPC so ENOENT/EACCES and
// other launch failures leave this server alive and return a useful
// error. The shim itself waits until after the acknowledgement and
// deferred exit before binding the replacement server.
yield* host
.spawnDetached("/bin/sh", [
"-c",
'sleep 3; exec "$@"',
"t3-self-update",
host.execPath,
runtimePaths.entryPath,
...host.cliArgs,
])
.pipe(
Effect.mapError((cause) =>
failWith("Could not start the replacement t3 process.", cause),
),
);
yield* Effect.logInfo("Server self-update installed; respawning.", { targetVersion });
yield* scheduleRestart(
Effect.try({
try: () => host.exitProcess(),
catch: (cause) => failWith("Could not exit the replaced t3 process.", cause),
}).pipe(
Effect.catch((error) =>
Effect.logError("Server self-update could not exit the replaced process.").pipe(
Effect.annotateLogs({ targetVersion, error: error.reason }),
Effect.ensuring(Ref.set(inFlight, false)),
),
),
),
);
}
return { targetVersion, method: activeMethod };
}).pipe(Effect.onError(() => Ref.set(inFlight, false)));
});
return ServerSelfUpdate.of({ update });
});
export const layer = Layer.effect(ServerSelfUpdate, make()).pipe(
Layer.provide(ProcessRunner.layer),
);