diff --git a/apps/server/src/cloud/bootService.ts b/apps/server/src/cloud/bootService.ts index 88d477974ae0a..de2e0f4e6c622 100644 --- a/apps/server/src/cloud/bootService.ts +++ b/apps/server/src/cloud/bootService.ts @@ -212,6 +212,16 @@ export interface BootServiceManager { readonly finalize: ReadonlyArray; } +const defaultSystemdUnitDir = (path: Path.Path, homeDir: string, xdgConfigHome: string) => + path.join( + path.isAbsolute(xdgConfigHome) ? xdgConfigHome : path.join(homeDir, ".config"), + "systemd", + "user", + ); + +const launchdPlistPath = (path: Path.Path, homeDir: string) => + path.join(homeDir, "Library", "LaunchAgents", BOOT_SERVICE_PLIST_FILE); + function systemdManager(input: { readonly path: Path.Path; readonly unitDir: string; @@ -288,12 +298,7 @@ function launchdManager(input: { readonly uid: number; readonly environmentPath: string; }): BootServiceManager { - const unitPath = input.path.join( - input.homeDir, - "Library", - "LaunchAgents", - BOOT_SERVICE_PLIST_FILE, - ); + const unitPath = launchdPlistPath(input.path, input.homeDir); const domainTarget = `gui/${input.uid}`; const serviceTarget = `${domainTarget}/${BOOT_SERVICE_LAUNCHD_LABEL}`; // Unloaded jobs may reject bootout/enable. Bootstrap starts RunAtLoad/KeepAlive jobs; kickstart would restart them. @@ -367,13 +372,7 @@ function selectBootServiceManager(input: { path: input.path, unitDir: input.systemdUnitDir ?? - input.path.join( - input.path.isAbsolute(input.xdgConfigHome) - ? input.xdgConfigHome - : input.path.join(input.homeDir, ".config"), - "systemd", - "user", - ), + defaultSystemdUnitDir(input.path, input.homeDir, input.xdgConfigHome), }); } if (input.platform === "darwin" && input.uid !== undefined) { @@ -423,6 +422,20 @@ const readSystemdUnitDirectory = Effect.fn("cloud.boot_service.read_systemd_unit Effect.orElseSucceed(() => undefined), ); +/** Where this platform's service manager keeps the unit, whether or not one is installed. */ +export const bootServiceUnitPath = Effect.fn("cloud.boot_service.unit_path")(function* () { + const path = yield* Path.Path; + const platform = yield* HostProcess.Platform; + const homeDir = yield* HostProcess.HomeDirectory; + if (homeDir === "") return undefined; + if (platform === "darwin") return launchdPlistPath(path, homeDir); + if (platform !== "linux") return undefined; + const unitDir = + (yield* readSystemdUnitDirectory()) ?? + defaultSystemdUnitDir(path, homeDir, (yield* HostProcess.Environment).XDG_CONFIG_HOME ?? ""); + return path.join(unitDir, BOOT_SERVICE_UNIT_FILE); +}); + export class BootServiceUnsupportedError extends Schema.TaggedError()( "BootServiceUnsupportedError", { platform: Schema.String }, diff --git a/apps/server/src/cloud/pinnedRuntime.test.ts b/apps/server/src/cloud/pinnedRuntime.test.ts index b84f3e78e136e..68870bd969436 100644 --- a/apps/server/src/cloud/pinnedRuntime.test.ts +++ b/apps/server/src/cloud/pinnedRuntime.test.ts @@ -511,6 +511,97 @@ it.layer(NodeServices.layer)("prunePinnedRuntimes", (it) => { }), ); + it.effect.each([ + { name: "its directory is on PATH", onPath: true, recorded: false, linkedHome: false }, + { + name: "an earlier prune recorded its entry as a launcher", + onPath: false, + recorded: true, + linkedHome: false, + }, + { + name: "its real directory in a symlinked home is on PATH", + onPath: true, + recorded: false, + linkedHome: true, + }, + ])("removes a runtime only its own entry names when $name", ({ onPath, recorded, linkedHome }) => + 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, "bin"); + yield* fs.makeDirectory(binDir); + yield* fs.symlink(path.join(versionsDir, "1.1.0", "t3"), path.join(binDir, "t3")); + const ownDir = path.join(versionsDir, "1.0.0"); + if (recorded) { + yield* fs.writeFileString( + path.join(baseDir, "runtime", "launchers"), + `${path.join(ownDir, "t3")}\n`, + ); + } + + const home = linkedHome + ? path.join(yield* fs.makeTempDirectoryScoped({ prefix: "t3-pinned-link-" }), "home") + : baseDir; + if (linkedHome) yield* fs.symlink(baseDir, home); + + // The launch agent puts the running runtime's own directory on PATH. + const removed = yield* prunePinnedRuntimes({ baseDir: home, keep: ["2.0.0"] }).pipe( + Effect.provideService( + ProcessRunner.ProcessRunner, + lsofRunner("p1\nftxt\nn/sbin/launchd\n"), + ), + Effect.provideService(HostProcess.Platform, "darwin"), + Effect.provideService(HostProcess.Environment, { + T3CODE_INSTALL_BIN_DIR: binDir, + PATH: onPath ? `${ownDir}:/usr/bin:/bin` : "/usr/bin:/bin", + }), + ); + assert.deepEqual(removed, ["1.0.0"]); + }), + ); + + it.effect.each([ + { name: "a launcher inside the versions directory", via: "bin" as const }, + { name: "a PATH directory linked to its version directory", via: "linked dir" as const }, + { + name: "a PATH directory in the versions directory linked to its version directory", + via: "linked versions dir" as const, + }, + ])("keeps a runtime $name names", ({ via }) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const { baseDir, versionsDir } = yield* setup(["1.0.0", "1.1.0"]); + let pathDirs = "/usr/bin:/bin"; + if (via === "bin") { + // The installer accepts any bin directory, including one under the versions directory. + const binDir = path.join(versionsDir, "bin"); + yield* fs.makeDirectory(binDir); + yield* fs.symlink(path.join(versionsDir, "1.0.0", "t3"), path.join(binDir, "t3")); + yield* fs.writeFileString( + path.join(baseDir, "runtime", "launchers"), + `${path.join(binDir, "t3")}\n`, + ); + } else { + const linkedDir = path.join(via === "linked dir" ? baseDir : versionsDir, "current"); + yield* fs.symlink(path.join(versionsDir, "1.0.0"), linkedDir); + pathDirs = `${linkedDir}:${pathDirs}`; + } + + const removed = yield* prunePinnedRuntimes({ baseDir, keep: ["2.0.0"] }).pipe( + Effect.provideService( + ProcessRunner.ProcessRunner, + lsofRunner("p1\nftxt\nn/sbin/launchd\n"), + ), + Effect.provideService(HostProcess.Platform, "darwin"), + Effect.provideService(HostProcess.Environment, { PATH: pathDirs }), + ); + assert.deepEqual(removed, ["1.1.0"]); + }), + ); + it.effect.each([ // An interrupted append cut this record short. { name: "an unterminated launcher record", recorded: "/opt/t", cut: "" }, diff --git a/apps/server/src/cloud/pinnedRuntime.ts b/apps/server/src/cloud/pinnedRuntime.ts index 90069f46f9173..77bcb39f09dcf 100644 --- a/apps/server/src/cloud/pinnedRuntime.ts +++ b/apps/server/src/cloud/pinnedRuntime.ts @@ -494,6 +494,9 @@ const shellLauncherTargets = Effect.fn("cloud.pinned_runtime.shell_launcher_targ const realVersionsDir = yield* fs .realPath(versionsDir) .pipe(Effect.orElseSucceed(() => versionsDir)); + const inVersionsDir = (candidate: string) => + launcherOwnsVersionsDir(path, versionsDir, candidate) || + launcherOwnsVersionsDir(path, realVersionsDir, candidate); const unrecorded: Array = []; const targets: Array = []; for (const launcher of [...recorded, ...found]) { @@ -506,13 +509,19 @@ const shellLauncherTargets = Effect.fn("cloud.pinned_runtime.shell_launcher_targ : // Fully resolved, through chained links and a symlinked T3 home alike. yield* fs.realPath(launcher).pipe(Effect.orElseSucceed(() => undefined)); if (target === undefined) continue; - targets.push(target); + // A runtime's own entry, on the macOS service PATH or recorded by an earlier prune, is no launcher. + // It is named as `versions//t3`, and neither it nor its directory is a link. + const entryDir = path.dirname(path.resolve(launcher)); + const parent = path.dirname(entryDir); if ( - (launcherOwnsVersionsDir(path, versionsDir, target) || - launcherOwnsVersionsDir(path, realVersionsDir, target)) && - !recorded.includes(launcher) && - !unrecorded.includes(launcher) + platform !== "win32" && + (parent === versionsDir || parent === realVersionsDir) && + target === path.join(realVersionsDir, path.basename(entryDir), path.basename(launcher)) ) { + continue; + } + targets.push(target); + if (inVersionsDir(target) && !recorded.includes(launcher) && !unrecorded.includes(launcher)) { unrecorded.push(launcher); } } diff --git a/apps/server/src/cloud/selfUpdate.test.ts b/apps/server/src/cloud/selfUpdate.test.ts index 6be4288ce35e4..e0927fe893d26 100644 --- a/apps/server/src/cloud/selfUpdate.test.ts +++ b/apps/server/src/cloud/selfUpdate.test.ts @@ -68,6 +68,19 @@ const makeHarness = Effect.fn("test.make_self_update_harness")(function* ( const runner = ProcessRunner.ProcessRunner.of({ run: (input) => Effect.gen(function* () { + if (input.command === "busctl") { + // No user bus: the unit directory falls back to XDG_CONFIG_HOME or ~/.config. + return { + stdout: "", + stderr: "", + code: ChildProcessSpawner.ExitCode(1), + timedOut: false, + stdoutTruncated: false, + stderrTruncated: false, + stdoutInvalidUtf8: false, + stderrInvalidUtf8: false, + }; + } if (input.command === "tar") { order.push("extract"); const stagingDir = input.args[input.args.indexOf("-C") + 1]; @@ -395,6 +408,8 @@ it.layer(NodeServices.layer)("pruning runtimes on start", (it) => { readonly executable: "own runtime" | "elsewhere"; readonly managed?: boolean; readonly symlinkedHome?: boolean; + readonly servicePlistStarts?: string; + readonly servicePlistThroughLink?: boolean; }) { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; @@ -412,6 +427,20 @@ it.layer(NodeServices.layer)("pruning runtimes on start", (it) => { yield* fs.utimes(sentinel, 0, 0); } if (input.symlinkedHome) yield* fs.symlink(realHome, baseDir); + const userHome = path.join(tempDir, "user"); + if (input.servicePlistStarts !== undefined) { + const agentsDir = path.join(userHome, "Library", "LaunchAgents"); + yield* fs.makeDirectory(agentsDir, { recursive: true }); + const programHome = input.servicePlistThroughLink + ? path.join(tempDir, "alias-home") + : baseDir; + if (input.servicePlistThroughLink) yield* fs.symlink(realHome, programHome); + const program = path.join(programHome, "runtime", "versions", input.servicePlistStarts, "t3"); + yield* fs.writeFileString( + path.join(agentsDir, "com.t3tools.t3code.service.plist"), + `\nProgramArguments\n\n${program}\n__service-launcher\n\n\n`, + ); + } // Like `process.execPath`, the running runtime is reported by its real path. const executablePath = input.executable === "own runtime" @@ -424,6 +453,7 @@ it.layer(NodeServices.layer)("pruning runtimes on start", (it) => { Effect.provideService(HostProcess.ExecutablePath, executablePath), Effect.provideService(HostProcess.Platform, "darwin"), Effect.provideService(HostProcess.Environment, {}), + Effect.provideService(HostProcess.HomeDirectory, userHome), Effect.provideService(ProcessRunner.ProcessRunner, otherProcesses), ); return { removed, remaining: (yield* fs.readDirectory(versionsDir)).toSorted() }; @@ -452,6 +482,22 @@ it.layer(NodeServices.layer)("pruning runtimes on start", (it) => { }), ); + // In-place updates move the active version but leave the launch agent on the launcher's runtime. + it.effect.each([ + { name: "", throughLink: false }, + { name: " through a symlinked T3 home", throughLink: true }, + ])("keeps the runtime a stopped service's launch agent starts$name", ({ throughLink }) => + Effect.gen(function* () { + const { removed, remaining } = yield* pruneOnStart({ + executable: "own runtime", + servicePlistStarts: staleVersion, + servicePlistThroughLink: throughLink, + }); + expect(removed).toEqual([]); + expect(remaining).toEqual([staleVersion, packageJson.version].toSorted()); + }), + ); + it.effect("leaves runtimes alone for an unmanaged server started elsewhere", () => Effect.gen(function* () { const { removed, remaining } = yield* pruneOnStart({ executable: "elsewhere" }); diff --git a/apps/server/src/cloud/selfUpdate.ts b/apps/server/src/cloud/selfUpdate.ts index 5e299964138da..3246152af0796 100644 --- a/apps/server/src/cloud/selfUpdate.ts +++ b/apps/server/src/cloud/selfUpdate.ts @@ -42,6 +42,7 @@ import { PinnedRuntimePreflightBlockedError, prunePinnedRuntimes, } from "./pinnedRuntime.ts"; +import { bootServiceProgramOf, bootServiceUnitPath } from "./bootService.ts"; import { decodeServicePreflightResult } from "./servicePreflight.ts"; import * as ServiceLauncherClient from "./serviceLauncherClient.ts"; import { isExactServiceVersion, SERVICE_LAUNCHER_PROTOCOL } from "./serviceProtocol.ts"; @@ -91,9 +92,24 @@ export const pruneRuntimesOnStart = Effect.fn("cloud.server_self_update.prune_on if (!input.managed && executablePath !== entryPath && executablePath !== realEntryPath) { return []; } + const unitPath = yield* bootServiceUnitPath(); + // A stopped service's unit still names the launcher runtime it will start. + const unit = + unitPath === undefined + ? Option.none() + : yield* fs.readFileString(unitPath).pipe(Effect.option); + const program = Option.flatMapNullishOr(unit, bootServiceProgramOf); + // The unit may name it through another spelling of a symlinked T3 home. + const references = Option.isNone(program) + ? [] + : [ + program.value, + yield* fs.realPath(program.value).pipe(Effect.orElseSucceed(() => program.value)), + ]; const removed = yield* prunePinnedRuntimes({ baseDir: input.baseDir, keep: [packageJson.version], + references, }); if (removed.length > 0) { yield* Effect.logInfo("Removed unused t3 runtimes.", { versions: removed });