import { HostProcessExecutablePath, HostProcessPlatform, HostProcessUserId,} from "@t3tools/shared/hostProcess";import * as Config from "effect/Config";import * as Context from "effect/Context";import * as DateTime from "effect/DateTime";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 Option from "effect/Option";import * as Path from "effect/Path";import * as Schema from "effect/Schema"; import * as ProcessRunner from "../processRunner.ts";import { ensurePinnedRuntimeInstalled, pinnedRuntimePaths, PinnedRuntimeInstallError,} from "./pinnedRuntime.ts";import { SERVICE_LAUNCHER_FILE, SERVICE_LAUNCHER_PROTOCOL, SERVICE_STATE_FILE, compareExactServiceVersions, parseServiceState, serviceStateActiveVersion, serviceStateHasPendingUpdate, type ServiceState,} from "./serviceProtocol.ts"; const BOOT_SERVICE_NAME = "t3code";const BOOT_SERVICE_UNIT_FILE = `${BOOT_SERVICE_NAME}.service`;// `.service` suffix keeps the label distinct from the desktop app's bundle id// (com.t3tools.t3code), so launchd and TCC records never collide.const BOOT_SERVICE_LAUNCHD_LABEL = "com.t3tools.t3code.service";const BOOT_SERVICE_PLIST_FILE = `${BOOT_SERVICE_LAUNCHD_LABEL}.plist`;const BOOT_SERVICE_UNIT_ENV = "T3_BOOT_SERVICE_UNIT"; /** systemd expands `%` specifiers, including in unquoted append-log paths. */function escapeSystemdSpecifiers(value: string): string { return value.replaceAll("%", "%%");} function quoteSystemdValue(value: string): string { const escaped = escapeSystemdSpecifiers(value); return /[\s"'\\]/.test(escaped) ? `"${escaped.replaceAll("\\", "\\\\").replaceAll('"', '\\"')}"` : escaped;} export interface BootServicePlan { readonly nodePath: string; readonly launcherPath: string; readonly baseDir: string; readonly logPath: string; readonly unitPath: string;} /** Pure renderer: service units cannot rely on the user's shell or PATH. */export function renderBootServiceUnit(plan: BootServicePlan): string { // The user manager has no reliable network-online target; server networking retries itself. return [ "[Unit]", "Description=T3 Code server", "StartLimitIntervalSec=300", "StartLimitBurst=5", "", "[Service]", "Type=simple", "WorkingDirectory=%h", `Environment=T3CODE_HOME=${quoteSystemdValue(plan.baseDir)}`, `Environment=${BOOT_SERVICE_UNIT_ENV}=${BOOT_SERVICE_UNIT_FILE}`, `ExecStart=${quoteSystemdValue(plan.nodePath)} ${quoteSystemdValue(plan.launcherPath)}`, // Let the launcher mark an explicit stop before it signals the server. // systemd still SIGKILLs the whole cgroup if graceful shutdown times out. "KillMode=mixed", // Agent tool calls run as children of the server, so they share this cgroup. // With the systemd default of OOMPolicy=stop, the kernel killing one greedy // child stops the whole unit: the server, every live agent, and the user's // connection. Keep running and let Restart=always cover the main process. "OOMPolicy=continue", "Restart=always", "RestartSec=5", `StandardOutput=append:${escapeSystemdSpecifiers(plan.logPath)}`, `StandardError=append:${escapeSystemdSpecifiers(plan.logPath)}`, "", "[Install]", "WantedBy=default.target", "", ].join("\n");} /** Plist values are emitted as XML text nodes; only these three need escaping. */function escapeXmlText(value: string): string { return value.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">");} /** Pure renderer: launch agents cannot rely on the user's shell or PATH. */export function renderBootServicePlist( plan: BootServicePlan, options: { readonly homeDir: string; readonly environmentPath: string },): string { // KeepAlive + ThrottleInterval mirror Restart=always + RestartSec=5. launchd // has no StartLimitBurst analog; a hard crash loop respawns every 5s forever. // ExitTimeOut 90 matches systemd's default TimeoutStopSec. A plain stop // completes within the launcher's 5s child grace, but a stop that queues // behind an in-flight update transition can take much longer; launchd's // system-defined default (5s on current macOS) would SIGKILL the launcher // (and, with it, the process group) mid-handoff. // ProcessType Interactive opts out of background-job resource throttling. // AbandonProcessGroup stays at its default (false): launchd reaps leftover // process-group members only when the launcher itself exits — the analog of // KillMode=mixed's final cgroup kill — and not when the launcher restarts its // child, so agent children survive server updates. return [ `<?xml version="1.0" encoding="UTF-8"?>`, `<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">`, `<plist version="1.0">`, `<dict>`, ` <key>Label</key>`, ` <string>${BOOT_SERVICE_LAUNCHD_LABEL}</string>`, ` <key>ProgramArguments</key>`, ` <array>`, ` <string>${escapeXmlText(plan.nodePath)}</string>`, ` <string>${escapeXmlText(plan.launcherPath)}</string>`, ` </array>`, ` <key>EnvironmentVariables</key>`, ` <dict>`, ` <key>PATH</key>`, ` <string>${escapeXmlText(options.environmentPath)}</string>`, ` <key>T3CODE_HOME</key>`, ` <string>${escapeXmlText(plan.baseDir)}</string>`, ` <key>${BOOT_SERVICE_UNIT_ENV}</key>`, ` <string>${BOOT_SERVICE_PLIST_FILE}</string>`, ` </dict>`, ` <key>WorkingDirectory</key>`, ` <string>${escapeXmlText(options.homeDir)}</string>`, ` <key>RunAtLoad</key>`, ` <true/>`, ` <key>KeepAlive</key>`, ` <true/>`, ` <key>ThrottleInterval</key>`, ` <integer>5</integer>`, ` <key>ExitTimeOut</key>`, ` <integer>90</integer>`, ` <key>ProcessType</key>`, ` <string>Interactive</string>`, ` <key>StandardOutPath</key>`, ` <string>${escapeXmlText(plan.logPath)}</string>`, ` <key>StandardErrorPath</key>`, ` <string>${escapeXmlText(plan.logPath)}</string>`, `</dict>`, `</plist>`, ``, ].join("\n");} export interface BootServiceStep { readonly step: string; readonly command: string; readonly args: ReadonlyArray<string>; /** * Non-zero exit is logged and ignored. Reserved for steps whose common * failures (not loaded, already enabled) leave a state a later strict step * either tolerates or fails loudly on. */ readonly optional?: boolean; /** Override the ProcessRunner default (60s) for steps that block longer. */ readonly timeout?: Duration.Input;} /** * Stop commands block until the service manager gives up: 90s by default for * systemd's TimeoutStopSec, and ExitTimeOut=90 in the rendered plist. This * must stay above both, or the runner cancels the stop mid-shutdown and the * next step races a still-loaded service. */const STOP_STEP_TIMEOUT = Duration.seconds(120); /** * Platform service-manager integration as data: paths, a pure renderer, and * the command steps each flow runs. install/uninstall/status consume this and * never branch on platform. */export interface BootServiceManager { readonly kind: "systemd" | "launchd"; readonly unitPath: string; readonly render: (plan: BootServicePlan) => string; /** Before rewriting files, when a unit is already installed. */ readonly stop: ReadonlyArray<BootServiceStep>; /** After files are written. The last entry starts the service. */ readonly activate: ReadonlyArray<BootServiceStep>; /** Best-effort recovery after a failed repair of an installed service. */ readonly restart: ReadonlyArray<BootServiceStep>; /** Uninstall, before the unit file is removed. */ readonly deactivate: ReadonlyArray<BootServiceStep>; /** Uninstall, after the unit file is removed. */ readonly finalize: ReadonlyArray<BootServiceStep>;} function systemdManager(input: { readonly path: Path.Path; readonly homeDir: string;}): BootServiceManager { const unitPath = input.path.join( input.homeDir, ".config", "systemd", "user", BOOT_SERVICE_UNIT_FILE, ); return { kind: "systemd", unitPath, render: renderBootServiceUnit, stop: [ { step: "stopping the installed service", command: "systemctl", args: ["--user", "stop", BOOT_SERVICE_UNIT_FILE], timeout: STOP_STEP_TIMEOUT, }, ], activate: [ { step: "reloading systemd user units", command: "systemctl", args: ["--user", "daemon-reload"], }, { step: "enabling the service", command: "systemctl", args: ["--user", "enable", BOOT_SERVICE_UNIT_FILE], }, // Start last. No administrative state write occurs after this succeeds. { step: "starting the service", command: "systemctl", args: ["--user", "restart", BOOT_SERVICE_UNIT_FILE], }, ], restart: [ { step: "restarting the service after a failed update", command: "systemctl", args: ["--user", "restart", BOOT_SERVICE_UNIT_FILE], }, ], deactivate: [ { step: "stopping the service", command: "systemctl", args: ["--user", "disable", "--now", BOOT_SERVICE_UNIT_FILE], timeout: STOP_STEP_TIMEOUT, }, ], finalize: [ { step: "reloading systemd user units", command: "systemctl", args: ["--user", "daemon-reload"], }, ], };} function launchdManager(input: { readonly path: Path.Path; readonly homeDir: string; readonly uid: number; readonly environmentPath: string;}): BootServiceManager { const unitPath = input.path.join( input.homeDir, "Library", "LaunchAgents", BOOT_SERVICE_PLIST_FILE, ); const domainTarget = `gui/${input.uid}`; const serviceTarget = `${domainTarget}/${BOOT_SERVICE_LAUNCHD_LABEL}`; // bootout/enable are optional: they fail on not-loaded states that are fine // to proceed from. The strict `bootstrap` runs last and is also the start: // loading a RunAtLoad/KeepAlive plist starts the job, so a separate // kickstart would kill and restart a server it just booted. A lingering job // that survived bootout, or a gui domain with nobody logged in at the // screen (SSH install), makes bootstrap fail the flow loudly rather than // silently keeping a stale server. return { kind: "launchd", unitPath, render: (plan) => renderBootServicePlist(plan, { homeDir: input.homeDir, environmentPath: input.environmentPath, }), // Without --wait, bootout returns in milliseconds while the job drains // for up to ExitTimeOut, and a bootstrap during the drain fails EIO. // --wait (present on modern macOS, absent from the man page) blocks until // the job is removed from the domain; STOP_STEP_TIMEOUT outlives it. stop: [ { step: "stopping the installed launch agent", command: "launchctl", args: ["bootout", "--wait", serviceTarget], optional: true, timeout: STOP_STEP_TIMEOUT, }, ], activate: [ // A persisted `launchctl disable` override refuses bootstrap; clear it. { step: "enabling the launch agent", command: "launchctl", args: ["enable", serviceTarget], optional: true, }, // Start last. No administrative state write occurs after this succeeds. { step: "starting the service", command: "launchctl", args: ["bootstrap", domainTarget, unitPath], }, ], restart: [ { step: "restarting the service after a failed update", command: "launchctl", args: ["bootstrap", domainTarget, unitPath], }, ], // No `launchctl disable` here: a persisted override would sabotage a // later reinstall. Removing the plist is what stops the next login load. // A bootout that fails for a reason other than "not loaded" leaves the // job running until logout; the failure is in the boot-service log. deactivate: [ { step: "stopping the service", command: "launchctl", args: ["bootout", "--wait", serviceTarget], optional: true, timeout: STOP_STEP_TIMEOUT, }, ], finalize: [], };} /** Undefined means this host cannot run the background service. */function selectBootServiceManager(input: { readonly platform: NodeJS.Platform; readonly homeDir: string; readonly uid: number | undefined; readonly path: Path.Path; readonly environmentPath: string;}): BootServiceManager | undefined { if (input.homeDir === "") { return undefined; } if (input.platform === "linux") { return systemdManager({ path: input.path, homeDir: input.homeDir }); } if (input.platform === "darwin" && input.uid !== undefined) { return launchdManager({ path: input.path, homeDir: input.homeDir, uid: input.uid, environmentPath: input.environmentPath, }); } return undefined;} export class BootServiceUnsupportedError extends Schema.TaggedErrorClass<BootServiceUnsupportedError>()( "BootServiceUnsupportedError", { platform: Schema.String },) { override get message(): string { return `Background setup supports Linux with systemd and macOS with launchd; this machine reports '${this.platform}'.`; }} export class BootServiceCommandError extends Schema.TaggedErrorClass<BootServiceCommandError>()( "BootServiceCommandError", { step: Schema.String, exitCode: Schema.optional(Schema.Number), stdoutLength: Schema.optional(Schema.Number), stderrLength: Schema.optional(Schema.Number), cause: Schema.optional(Schema.Defect()), },) { override get message(): string { return this.exitCode === undefined ? `Background setup failed while ${this.step}.` : `Background setup failed while ${this.step} (exit code ${this.exitCode}).`; }} export class BootServiceInstallError extends Schema.TaggedErrorClass<BootServiceInstallError>()( "BootServiceInstallError", { cause: Schema.Defect() },) { override get message(): string { return "Could not set up the T3 Code background service."; }} const BootServiceProblem = Schema.Literals([ "user-manager-unavailable", "linger-unavailable", "linger-disabled", "service-disabled", "service-stopped",]);type BootServiceProblem = typeof BootServiceProblem.Type; /** These codes and recovery steps are documented in docs/user/background-service.md. */export function formatBootServiceProblem(problem: BootServiceProblem): string { switch (problem) { case "user-manager-unavailable": return "Cannot reach the systemd user manager. Run `systemctl --user status` in a login session for the service user. Install your distribution's systemd user-session support if it is missing; do not run T3 with sudo."; case "linger-unavailable": return 'Cannot check whether this user can run services after logout. Run `loginctl show-user "$(id -un)" --property=Linger` and check that systemd-logind is available.'; case "linger-disabled": return 'Lingering is disabled. T3 Code will stop when your last login session ends and will not start at boot. Run `sudo loginctl enable-linger "$(id -un)"` on this machine, then retry the service command as your normal user.'; case "service-disabled": return "The service is not enabled to start automatically. Run `t3 service update` to repair it."; case "service-stopped": return "The service is not running. Check the service log and `systemctl --user status t3code.service`, then run `t3 service update`."; }} export class BootServicePrerequisiteError extends Schema.TaggedErrorClass<BootServicePrerequisiteError>()( "BootServicePrerequisiteError", { problem: BootServiceProblem, cause: Schema.optional(Schema.Defect()) },) { override get message(): string { return `[${this.problem}] ${formatBootServiceProblem(this.problem)}`; }} export class BootServiceUpdatePendingError extends Schema.TaggedErrorClass<BootServiceUpdatePendingError>()( "BootServiceUpdatePendingError", {},) { override get message(): string { return "A remote server update is still pending. Wait for it to finish, then retry."; }} export class BootServiceDowngradeRefusedError extends Schema.TaggedErrorClass<BootServiceDowngradeRefusedError>()( "BootServiceDowngradeRefusedError", { installedVersion: Schema.String, targetVersion: Schema.String, },) { override get message(): string { return `Refusing to replace t3@${this.installedVersion} with older t3@${this.targetVersion}. Run the command again with --allow-downgrade to continue.`; }} export type BootServiceError = | BootServiceUnsupportedError | BootServiceCommandError | BootServiceInstallError | BootServicePrerequisiteError | BootServiceUpdatePendingError | BootServiceDowngradeRefusedError; export interface BootServiceStatus { readonly supported: boolean; readonly installed: boolean; readonly current: boolean; readonly installedVersion?: string; readonly problems?: ReadonlyArray<BootServiceProblem>; readonly unitPath: string; readonly logPath: string;} export class BootService extends Context.Service< BootService, { readonly install: (options?: { readonly allowDowngrade?: boolean; }) => Effect.Effect<BootServicePlan, BootServiceError>; readonly uninstall: Effect.Effect<boolean, BootServiceError>; readonly status: Effect.Effect<BootServiceStatus, BootServiceError>; }>()("t3/cloud/bootService") {} export interface BootServiceHost { readonly execPath: string; readonly launcherSourcePath?: string;} export const make = Effect.fn("cloud.boot_service.make")(function* (input: {Lines 1–500 of 883. Load more
import { HostProcessExecutablePath, HostProcessPlatform, HostProcessUserId, } from "@t3tools/shared/hostProcess"; import * as Config from "effect/Config"; import * as Context from "effect/Context"; import * as DateTime from "effect/DateTime"; 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 Option from "effect/Option"; import * as Path from "effect/Path"; import * as Schema from "effect/Schema"; import * as ProcessRunner from "../processRunner.ts"; import { ensurePinnedRuntimeInstalled, pinnedRuntimePaths, PinnedRuntimeInstallError, } from "./pinnedRuntime.ts"; import { SERVICE_LAUNCHER_FILE, SERVICE_LAUNCHER_PROTOCOL, SERVICE_STATE_FILE, compareExactServiceVersions, parseServiceState, serviceStateActiveVersion, serviceStateHasPendingUpdate, type ServiceState, } from "./serviceProtocol.ts"; const BOOT_SERVICE_NAME = "t3code"; const BOOT_SERVICE_UNIT_FILE = `${BOOT_SERVICE_NAME}.service`; // `.service` suffix keeps the label distinct from the desktop app's bundle id // (com.t3tools.t3code), so launchd and TCC records never collide. const BOOT_SERVICE_LAUNCHD_LABEL = "com.t3tools.t3code.service"; const BOOT_SERVICE_PLIST_FILE = `${BOOT_SERVICE_LAUNCHD_LABEL}.plist`; const BOOT_SERVICE_UNIT_ENV = "T3_BOOT_SERVICE_UNIT"; /** systemd expands `%` specifiers, including in unquoted append-log paths. */ function escapeSystemdSpecifiers(value: string): string { return value.replaceAll("%", "%%"); } function quoteSystemdValue(value: string): string { const escaped = escapeSystemdSpecifiers(value); return /[\s"'\\]/.test(escaped) ? `"${escaped.replaceAll("\\", "\\\\").replaceAll('"', '\\"')}"` : escaped; } export interface BootServicePlan { readonly nodePath: string; readonly launcherPath: string; readonly baseDir: string; readonly logPath: string; readonly unitPath: string; } /** Pure renderer: service units cannot rely on the user's shell or PATH. */ export function renderBootServiceUnit(plan: BootServicePlan): string { // The user manager has no reliable network-online target; server networking retries itself. return [ "[Unit]", "Description=T3 Code server", "StartLimitIntervalSec=300", "StartLimitBurst=5", "", "[Service]", "Type=simple", "WorkingDirectory=%h", `Environment=T3CODE_HOME=${quoteSystemdValue(plan.baseDir)}`, `Environment=${BOOT_SERVICE_UNIT_ENV}=${BOOT_SERVICE_UNIT_FILE}`, `ExecStart=${quoteSystemdValue(plan.nodePath)} ${quoteSystemdValue(plan.launcherPath)}`, // Let the launcher mark an explicit stop before it signals the server. // systemd still SIGKILLs the whole cgroup if graceful shutdown times out. "KillMode=mixed", // Agent tool calls run as children of the server, so they share this cgroup. // With the systemd default of OOMPolicy=stop, the kernel killing one greedy // child stops the whole unit: the server, every live agent, and the user's // connection. Keep running and let Restart=always cover the main process. "OOMPolicy=continue", "Restart=always", "RestartSec=5", `StandardOutput=append:${escapeSystemdSpecifiers(plan.logPath)}`, `StandardError=append:${escapeSystemdSpecifiers(plan.logPath)}`, "", "[Install]", "WantedBy=default.target", "", ].join("\n"); } /** Plist values are emitted as XML text nodes; only these three need escaping. */ function escapeXmlText(value: string): string { return value.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">"); } /** Pure renderer: launch agents cannot rely on the user's shell or PATH. */ export function renderBootServicePlist( plan: BootServicePlan, options: { readonly homeDir: string; readonly environmentPath: string }, ): string { // KeepAlive + ThrottleInterval mirror Restart=always + RestartSec=5. launchd // has no StartLimitBurst analog; a hard crash loop respawns every 5s forever. // ExitTimeOut 90 matches systemd's default TimeoutStopSec. A plain stop // completes within the launcher's 5s child grace, but a stop that queues // behind an in-flight update transition can take much longer; launchd's // system-defined default (5s on current macOS) would SIGKILL the launcher // (and, with it, the process group) mid-handoff. // ProcessType Interactive opts out of background-job resource throttling. // AbandonProcessGroup stays at its default (false): launchd reaps leftover // process-group members only when the launcher itself exits — the analog of // KillMode=mixed's final cgroup kill — and not when the launcher restarts its // child, so agent children survive server updates. return [ `<?xml version="1.0" encoding="UTF-8"?>`, `<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">`, `<plist version="1.0">`, `<dict>`, ` <key>Label</key>`, ` <string>${BOOT_SERVICE_LAUNCHD_LABEL}</string>`, ` <key>ProgramArguments</key>`, ` <array>`, ` <string>${escapeXmlText(plan.nodePath)}</string>`, ` <string>${escapeXmlText(plan.launcherPath)}</string>`, ` </array>`, ` <key>EnvironmentVariables</key>`, ` <dict>`, ` <key>PATH</key>`, ` <string>${escapeXmlText(options.environmentPath)}</string>`, ` <key>T3CODE_HOME</key>`, ` <string>${escapeXmlText(plan.baseDir)}</string>`, ` <key>${BOOT_SERVICE_UNIT_ENV}</key>`, ` <string>${BOOT_SERVICE_PLIST_FILE}</string>`, ` </dict>`, ` <key>WorkingDirectory</key>`, ` <string>${escapeXmlText(options.homeDir)}</string>`, ` <key>RunAtLoad</key>`, ` <true/>`, ` <key>KeepAlive</key>`, ` <true/>`, ` <key>ThrottleInterval</key>`, ` <integer>5</integer>`, ` <key>ExitTimeOut</key>`, ` <integer>90</integer>`, ` <key>ProcessType</key>`, ` <string>Interactive</string>`, ` <key>StandardOutPath</key>`, ` <string>${escapeXmlText(plan.logPath)}</string>`, ` <key>StandardErrorPath</key>`, ` <string>${escapeXmlText(plan.logPath)}</string>`, `</dict>`, `</plist>`, ``, ].join("\n"); } export interface BootServiceStep { readonly step: string; readonly command: string; readonly args: ReadonlyArray<string>; /** * Non-zero exit is logged and ignored. Reserved for steps whose common * failures (not loaded, already enabled) leave a state a later strict step * either tolerates or fails loudly on. */ readonly optional?: boolean; /** Override the ProcessRunner default (60s) for steps that block longer. */ readonly timeout?: Duration.Input; } /** * Stop commands block until the service manager gives up: 90s by default for * systemd's TimeoutStopSec, and ExitTimeOut=90 in the rendered plist. This * must stay above both, or the runner cancels the stop mid-shutdown and the * next step races a still-loaded service. */ const STOP_STEP_TIMEOUT = Duration.seconds(120); /** * Platform service-manager integration as data: paths, a pure renderer, and * the command steps each flow runs. install/uninstall/status consume this and * never branch on platform. */ export interface BootServiceManager { readonly kind: "systemd" | "launchd"; readonly unitPath: string; readonly render: (plan: BootServicePlan) => string; /** Before rewriting files, when a unit is already installed. */ readonly stop: ReadonlyArray<BootServiceStep>; /** After files are written. The last entry starts the service. */ readonly activate: ReadonlyArray<BootServiceStep>; /** Best-effort recovery after a failed repair of an installed service. */ readonly restart: ReadonlyArray<BootServiceStep>; /** Uninstall, before the unit file is removed. */ readonly deactivate: ReadonlyArray<BootServiceStep>; /** Uninstall, after the unit file is removed. */ readonly finalize: ReadonlyArray<BootServiceStep>; } function systemdManager(input: { readonly path: Path.Path; readonly homeDir: string; }): BootServiceManager { const unitPath = input.path.join( input.homeDir, ".config", "systemd", "user", BOOT_SERVICE_UNIT_FILE, ); return { kind: "systemd", unitPath, render: renderBootServiceUnit, stop: [ { step: "stopping the installed service", command: "systemctl", args: ["--user", "stop", BOOT_SERVICE_UNIT_FILE], timeout: STOP_STEP_TIMEOUT, }, ], activate: [ { step: "reloading systemd user units", command: "systemctl", args: ["--user", "daemon-reload"], }, { step: "enabling the service", command: "systemctl", args: ["--user", "enable", BOOT_SERVICE_UNIT_FILE], }, // Start last. No administrative state write occurs after this succeeds. { step: "starting the service", command: "systemctl", args: ["--user", "restart", BOOT_SERVICE_UNIT_FILE], }, ], restart: [ { step: "restarting the service after a failed update", command: "systemctl", args: ["--user", "restart", BOOT_SERVICE_UNIT_FILE], }, ], deactivate: [ { step: "stopping the service", command: "systemctl", args: ["--user", "disable", "--now", BOOT_SERVICE_UNIT_FILE], timeout: STOP_STEP_TIMEOUT, }, ], finalize: [ { step: "reloading systemd user units", command: "systemctl", args: ["--user", "daemon-reload"], }, ], }; } function launchdManager(input: { readonly path: Path.Path; readonly homeDir: string; readonly uid: number; readonly environmentPath: string; }): BootServiceManager { const unitPath = input.path.join( input.homeDir, "Library", "LaunchAgents", BOOT_SERVICE_PLIST_FILE, ); const domainTarget = `gui/${input.uid}`; const serviceTarget = `${domainTarget}/${BOOT_SERVICE_LAUNCHD_LABEL}`; // bootout/enable are optional: they fail on not-loaded states that are fine // to proceed from. The strict `bootstrap` runs last and is also the start: // loading a RunAtLoad/KeepAlive plist starts the job, so a separate // kickstart would kill and restart a server it just booted. A lingering job // that survived bootout, or a gui domain with nobody logged in at the // screen (SSH install), makes bootstrap fail the flow loudly rather than // silently keeping a stale server. return { kind: "launchd", unitPath, render: (plan) => renderBootServicePlist(plan, { homeDir: input.homeDir, environmentPath: input.environmentPath, }), // Without --wait, bootout returns in milliseconds while the job drains // for up to ExitTimeOut, and a bootstrap during the drain fails EIO. // --wait (present on modern macOS, absent from the man page) blocks until // the job is removed from the domain; STOP_STEP_TIMEOUT outlives it. stop: [ { step: "stopping the installed launch agent", command: "launchctl", args: ["bootout", "--wait", serviceTarget], optional: true, timeout: STOP_STEP_TIMEOUT, }, ], activate: [ // A persisted `launchctl disable` override refuses bootstrap; clear it. { step: "enabling the launch agent", command: "launchctl", args: ["enable", serviceTarget], optional: true, }, // Start last. No administrative state write occurs after this succeeds. { step: "starting the service", command: "launchctl", args: ["bootstrap", domainTarget, unitPath], }, ], restart: [ { step: "restarting the service after a failed update", command: "launchctl", args: ["bootstrap", domainTarget, unitPath], }, ], // No `launchctl disable` here: a persisted override would sabotage a // later reinstall. Removing the plist is what stops the next login load. // A bootout that fails for a reason other than "not loaded" leaves the // job running until logout; the failure is in the boot-service log. deactivate: [ { step: "stopping the service", command: "launchctl", args: ["bootout", "--wait", serviceTarget], optional: true, timeout: STOP_STEP_TIMEOUT, }, ], finalize: [], }; } /** Undefined means this host cannot run the background service. */ function selectBootServiceManager(input: { readonly platform: NodeJS.Platform; readonly homeDir: string; readonly uid: number | undefined; readonly path: Path.Path; readonly environmentPath: string; }): BootServiceManager | undefined { if (input.homeDir === "") { return undefined; } if (input.platform === "linux") { return systemdManager({ path: input.path, homeDir: input.homeDir }); } if (input.platform === "darwin" && input.uid !== undefined) { return launchdManager({ path: input.path, homeDir: input.homeDir, uid: input.uid, environmentPath: input.environmentPath, }); } return undefined; } export class BootServiceUnsupportedError extends Schema.TaggedErrorClass<BootServiceUnsupportedError>()( "BootServiceUnsupportedError", { platform: Schema.String }, ) { override get message(): string { return `Background setup supports Linux with systemd and macOS with launchd; this machine reports '${this.platform}'.`; } } export class BootServiceCommandError extends Schema.TaggedErrorClass<BootServiceCommandError>()( "BootServiceCommandError", { step: Schema.String, exitCode: Schema.optional(Schema.Number), stdoutLength: Schema.optional(Schema.Number), stderrLength: Schema.optional(Schema.Number), cause: Schema.optional(Schema.Defect()), }, ) { override get message(): string { return this.exitCode === undefined ? `Background setup failed while ${this.step}.` : `Background setup failed while ${this.step} (exit code ${this.exitCode}).`; } } export class BootServiceInstallError extends Schema.TaggedErrorClass<BootServiceInstallError>()( "BootServiceInstallError", { cause: Schema.Defect() }, ) { override get message(): string { return "Could not set up the T3 Code background service."; } } const BootServiceProblem = Schema.Literals([ "user-manager-unavailable", "linger-unavailable", "linger-disabled", "service-disabled", "service-stopped", ]); type BootServiceProblem = typeof BootServiceProblem.Type; /** These codes and recovery steps are documented in docs/user/background-service.md. */ export function formatBootServiceProblem(problem: BootServiceProblem): string { switch (problem) { case "user-manager-unavailable": return "Cannot reach the systemd user manager. Run `systemctl --user status` in a login session for the service user. Install your distribution's systemd user-session support if it is missing; do not run T3 with sudo."; case "linger-unavailable": return 'Cannot check whether this user can run services after logout. Run `loginctl show-user "$(id -un)" --property=Linger` and check that systemd-logind is available.'; case "linger-disabled": return 'Lingering is disabled. T3 Code will stop when your last login session ends and will not start at boot. Run `sudo loginctl enable-linger "$(id -un)"` on this machine, then retry the service command as your normal user.'; case "service-disabled": return "The service is not enabled to start automatically. Run `t3 service update` to repair it."; case "service-stopped": return "The service is not running. Check the service log and `systemctl --user status t3code.service`, then run `t3 service update`."; } } export class BootServicePrerequisiteError extends Schema.TaggedErrorClass<BootServicePrerequisiteError>()( "BootServicePrerequisiteError", { problem: BootServiceProblem, cause: Schema.optional(Schema.Defect()) }, ) { override get message(): string { return `[${this.problem}] ${formatBootServiceProblem(this.problem)}`; } } export class BootServiceUpdatePendingError extends Schema.TaggedErrorClass<BootServiceUpdatePendingError>()( "BootServiceUpdatePendingError", {}, ) { override get message(): string { return "A remote server update is still pending. Wait for it to finish, then retry."; } } export class BootServiceDowngradeRefusedError extends Schema.TaggedErrorClass<BootServiceDowngradeRefusedError>()( "BootServiceDowngradeRefusedError", { installedVersion: Schema.String, targetVersion: Schema.String, }, ) { override get message(): string { return `Refusing to replace t3@${this.installedVersion} with older t3@${this.targetVersion}. Run the command again with --allow-downgrade to continue.`; } } export type BootServiceError = | BootServiceUnsupportedError | BootServiceCommandError | BootServiceInstallError | BootServicePrerequisiteError | BootServiceUpdatePendingError | BootServiceDowngradeRefusedError; export interface BootServiceStatus { readonly supported: boolean; readonly installed: boolean; readonly current: boolean; readonly installedVersion?: string; readonly problems?: ReadonlyArray<BootServiceProblem>; readonly unitPath: string; readonly logPath: string; } export class BootService extends Context.Service< BootService, { readonly install: (options?: { readonly allowDowngrade?: boolean; }) => Effect.Effect<BootServicePlan, BootServiceError>; readonly uninstall: Effect.Effect<boolean, BootServiceError>; readonly status: Effect.Effect<BootServiceStatus, BootServiceError>; } >()("t3/cloud/bootService") {} export interface BootServiceHost { readonly execPath: string; readonly launcherSourcePath?: string; } export const make = Effect.fn("cloud.boot_service.make")(function* (input: {