diff --git a/apps/desktop/src/app/DesktopApp.ts b/apps/desktop/src/app/DesktopApp.ts index fd86c5f05..410184053 100644 --- a/apps/desktop/src/app/DesktopApp.ts +++ b/apps/desktop/src/app/DesktopApp.ts @@ -9,6 +9,7 @@ import * as Crypto from "effect/Crypto"; import * as ElectronApp from "../electron/ElectronApp.ts"; import * as ElectronDialog from "../electron/ElectronDialog.ts"; import * as ElectronProtocol from "../electron/ElectronProtocol.ts"; +import * as ElectronSafeStorage from "../electron/ElectronSafeStorage.ts"; import { installDesktopIpcHandlers } from "../ipc/DesktopIpcHandlers.ts"; import * as DesktopAppIdentity from "./DesktopAppIdentity.ts"; import * as DesktopClerk from "./DesktopClerk.ts"; @@ -17,7 +18,9 @@ import * as DesktopWindow from "../window/DesktopWindow.ts"; import * as DesktopBackendPool from "../backend/DesktopBackendPool.ts"; import * as DesktopEnvironment from "./DesktopEnvironment.ts"; import * as DesktopLifecycle from "./DesktopLifecycle.ts"; +import * as DesktopLinuxUrlHandler from "./DesktopLinuxUrlHandler.ts"; import * as DesktopObservability from "./DesktopObservability.ts"; +import * as DesktopPreReadyPlatform from "./DesktopPreReadyPlatform.ts"; import * as DesktopShutdown from "./DesktopShutdown.ts"; import * as DesktopServerExposure from "../backend/DesktopServerExposure.ts"; import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts"; @@ -220,20 +223,49 @@ const startup = Effect.gen(function* () { const applicationMenu = yield* DesktopApplicationMenu.DesktopApplicationMenu; const electronApp = yield* ElectronApp.ElectronApp; const lifecycle = yield* DesktopLifecycle.DesktopLifecycle; + const linuxUrlHandler = yield* DesktopLinuxUrlHandler.DesktopLinuxUrlHandler; const clerk = yield* DesktopClerk.DesktopClerk; const shellEnvironment = yield* DesktopShellEnvironment.DesktopShellEnvironment; const desktopSettings = yield* DesktopAppSettings.DesktopAppSettings; + const preReadyElectronOptions = yield* DesktopPreReadyPlatform.DesktopPreReadyElectronOptions; + const safeStorage = yield* ElectronSafeStorage.ElectronSafeStorage; const updates = yield* DesktopUpdates.DesktopUpdates; const environment = yield* DesktopEnvironment.DesktopEnvironment; yield* shellEnvironment.installIntoProcess; + const hasCommandLinePasswordStore = + preReadyElectronOptions.linuxPasswordStoreCommandLine !== null; + const linuxElectronOptions = + environment.platform === "linux" && !hasCommandLinePasswordStore + ? DesktopPreReadyPlatform.resolveEarlyLinuxElectronOptionsFromProcess() + : preReadyElectronOptions.linux; + if (linuxElectronOptions !== null && !hasCommandLinePasswordStore) { + if ( + linuxElectronOptions.passwordStore !== null || + preReadyElectronOptions.linux?.passwordStore !== null + ) { + yield* electronApp.removeCommandLineSwitch("password-store"); + } + if (linuxElectronOptions.passwordStore !== null) { + yield* electronApp.appendCommandLineSwitch( + "password-store", + linuxElectronOptions.passwordStore, + ); + } + } const userDataPath = yield* appIdentity.resolveUserDataPath; yield* electronApp.setPath("userData", userDataPath); yield* logStartupInfo("runtime logging configured", { logDir: environment.logDir }); yield* desktopSettings.load; - if (environment.platform === "linux") { - yield* electronApp.appendCommandLineSwitch("class", environment.linuxWmClass); + if (linuxElectronOptions !== null) { + yield* logStartupInfo("linux password store configured", { + passwordStore: hasCommandLinePasswordStore + ? "command-line" + : (linuxElectronOptions.passwordStore ?? "electron-default"), + xdgCurrentDesktop: process.env.XDG_CURRENT_DESKTOP ?? null, + xdgSessionDesktop: process.env.XDG_SESSION_DESKTOP ?? null, + }); } yield* appIdentity.configure; @@ -245,9 +277,16 @@ const startup = Effect.gen(function* () { Effect.catchCause((cause) => fatalStartupCause("whenReady", cause)), ); yield* logStartupInfo("app ready"); + if (environment.platform === "linux") { + const selectedBackend = yield* safeStorage.selectedStorageBackend; + yield* logStartupInfo("safe storage ready", { + backend: Option.getOrElse(selectedBackend, () => "unknown"), + }); + } yield* appIdentity.configure; yield* applicationMenu.configure; yield* updates.configure; + yield* linuxUrlHandler.register; yield* bootstrap.pipe(Effect.catchCause((cause) => fatalStartupCause("bootstrap", cause))); }).pipe(Effect.withSpan("desktop.startup")); diff --git a/apps/desktop/src/app/DesktopAppIdentity.test.ts b/apps/desktop/src/app/DesktopAppIdentity.test.ts index 6c5385e70..de945054c 100644 --- a/apps/desktop/src/app/DesktopAppIdentity.test.ts +++ b/apps/desktop/src/app/DesktopAppIdentity.test.ts @@ -64,6 +64,7 @@ const makeElectronAppLayer = (calls: ElectronAppCalls) => }), appendCommandLineSwitch: () => Effect.void, onBeforeQuitForUpdate: () => Effect.void, + removeCommandLineSwitch: () => Effect.void, on: () => Effect.void, } satisfies ElectronApp.ElectronApp["Service"]); diff --git a/apps/desktop/src/app/DesktopConfig.ts b/apps/desktop/src/app/DesktopConfig.ts index 4bf6b5133..d157a4c6b 100644 --- a/apps/desktop/src/app/DesktopConfig.ts +++ b/apps/desktop/src/app/DesktopConfig.ts @@ -35,6 +35,7 @@ const compactEnv = (env: Readonly>): Record return decoded.slice("encrypted:".length); }); }, + selectedStorageBackend: Effect.succeed(Option.none()), } satisfies ElectronSafeStorage.ElectronSafeStorage["Service"]); } diff --git a/apps/desktop/src/app/DesktopEarlyElectronStartup.test.ts b/apps/desktop/src/app/DesktopEarlyElectronStartup.test.ts new file mode 100644 index 000000000..b7647b5cc --- /dev/null +++ b/apps/desktop/src/app/DesktopEarlyElectronStartup.test.ts @@ -0,0 +1,121 @@ +// @effect-diagnostics nodeBuiltinImport:off - tests use POSIX path joining to match the Linux startup boundary. +import * as NodePath from "node:path"; +import { assert, describe, it } from "@effect/vitest"; + +import { + resolveEarlyLinuxElectronOptions, + resolveEarlyLinuxPasswordStorePreference, +} from "./DesktopEarlyElectronStartup.ts"; + +describe("DesktopEarlyElectronStartup", () => { + const joinPath = NodePath.posix.join; + + it("reads the persisted linux password-store preference before Electron is ready", () => { + const preference = resolveEarlyLinuxPasswordStorePreference({ + env: { T3CODE_HOME: "/home/user/.t3-test" }, + homeDirectory: "/home/user", + joinPath, + readFileString: (path) => { + assert.equal(path, "/home/user/.t3-test/userdata/desktop-settings.json"); + return JSON.stringify({ linuxPasswordStore: "kwallet6" }); + }, + }); + + assert.equal(preference, "kwallet6"); + }); + + it("accepts JSONC in the early desktop settings file", () => { + const preference = resolveEarlyLinuxPasswordStorePreference({ + env: { T3CODE_HOME: "/home/user/.t3-test" }, + homeDirectory: "/home/user", + joinPath, + readFileString: () => `{ + // manually edited setting + "linuxPasswordStore": "gnome-libsecret", + }`, + }); + + assert.equal(preference, "gnome-libsecret"); + }); + + it("falls back to auto when the early settings document is missing or invalid", () => { + const preference = resolveEarlyLinuxPasswordStorePreference({ + env: {}, + homeDirectory: "/home/user", + joinPath, + readFileString: () => { + throw new Error("missing"); + }, + }); + + assert.equal(preference, "auto"); + }); + + it("preserves absolute root paths when resolving early settings", () => { + const preference = resolveEarlyLinuxPasswordStorePreference({ + env: { T3CODE_HOME: "/" }, + homeDirectory: "/home/user", + joinPath, + readFileString: (path) => { + assert.equal(path, "/userdata/desktop-settings.json"); + return JSON.stringify({ linuxPasswordStore: "kwallet6" }); + }, + }); + + assert.equal(preference, "kwallet6"); + }); + + it("resolves the early linux Electron switches", () => { + const options = resolveEarlyLinuxElectronOptions({ + env: { + T3CODE_HOME: "/home/user/.t3-test", + XDG_CURRENT_DESKTOP: "niri", + VITE_DEV_SERVER_URL: "http://127.0.0.1:5173", + }, + homeDirectory: "/home/user", + joinPath, + readFileString: (path) => { + assert.equal(path, "/home/user/.t3-test/userdata/desktop-settings.json"); + return JSON.stringify({ linuxPasswordStore: "auto" }); + }, + }); + + assert.deepEqual(options, { + linuxWmClass: "t3code-dev", + passwordStore: "gnome-libsecret", + }); + }); + + it("keeps implicit development state under ~/.t3/dev when T3CODE_HOME is unset", () => { + const preference = resolveEarlyLinuxPasswordStorePreference({ + env: { + VITE_DEV_SERVER_URL: "http://127.0.0.1:5173", + }, + homeDirectory: "/home/user", + joinPath, + readFileString: (path) => { + assert.equal(path, "/home/user/.t3/dev/desktop-settings.json"); + return JSON.stringify({ linuxPasswordStore: "kwallet" }); + }, + }); + + assert.equal(preference, "kwallet"); + }); + + it("treats whitespace-only T3CODE_HOME as unconfigured in development", () => { + const preference = resolveEarlyLinuxPasswordStorePreference({ + env: { + T3CODE_HOME: " ", + VITE_DEV_SERVER_URL: "http://127.0.0.1:5173", + }, + homeDirectory: "/home/user", + joinPath, + readFileString: (path) => { + assert.equal(path, "/home/user/.t3/dev/desktop-settings.json"); + return JSON.stringify({ linuxPasswordStore: "gnome-libsecret" }); + }, + }); + + assert.equal(preference, "gnome-libsecret"); + }); +}); diff --git a/apps/desktop/src/app/DesktopEarlyElectronStartup.ts b/apps/desktop/src/app/DesktopEarlyElectronStartup.ts new file mode 100644 index 000000000..3e11d7961 --- /dev/null +++ b/apps/desktop/src/app/DesktopEarlyElectronStartup.ts @@ -0,0 +1,90 @@ +import { fromLenientJson } from "@t3tools/shared/schemaJson"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; + +import { + DEFAULT_LINUX_PASSWORD_STORE, + normalizeLinuxPasswordStorePreference, + resolveLinuxPasswordStoreSwitch, + type LinuxPasswordStoreSwitch, + type LinuxPasswordStorePreference, +} from "../linuxSecretStorage.ts"; +import { + resolveDesktopBaseDir, + resolveDesktopStateDir, + type JoinPath, +} from "./DesktopStatePaths.ts"; + +interface EarlyDesktopSettingsInput { + readonly env: NodeJS.ProcessEnv; + readonly homeDirectory: string; + readonly joinPath: JoinPath; + readonly readFileString: (path: string) => string; +} + +type EarlyLinuxElectronOptionsInput = EarlyDesktopSettingsInput; + +export interface EarlyLinuxElectronOptions { + readonly linuxWmClass: string; + readonly passwordStore: LinuxPasswordStoreSwitch | null; +} + +const trimNonEmpty = (value: string | undefined): string | null => { + const trimmed = value?.trim(); + return trimmed && trimmed.length > 0 ? trimmed : null; +}; + +const EarlyDesktopSettingsJson = fromLenientJson( + Schema.Struct({ + linuxPasswordStore: Schema.optionalKey(Schema.Unknown), + }), +); +const decodeEarlyDesktopSettingsJson = Schema.decodeSync(EarlyDesktopSettingsJson); + +const isDevelopmentEnvironment = (env: NodeJS.ProcessEnv): boolean => + trimNonEmpty(env.VITE_DEV_SERVER_URL) !== null; + +function resolveEarlyDesktopSettingsPath(input: { + readonly env: NodeJS.ProcessEnv; + readonly homeDirectory: string; + readonly joinPath: JoinPath; +}): string { + const t3Home = Option.fromUndefinedOr(input.env.T3CODE_HOME); + const baseDir = resolveDesktopBaseDir({ + homeDirectory: input.homeDirectory, + joinPath: input.joinPath, + t3Home, + }); + const stateDir = resolveDesktopStateDir({ + baseDir, + isDevelopment: isDevelopmentEnvironment(input.env), + joinPath: input.joinPath, + t3Home, + }); + return input.joinPath(stateDir, "desktop-settings.json"); +} + +export function resolveEarlyLinuxPasswordStorePreference( + input: EarlyDesktopSettingsInput, +): LinuxPasswordStorePreference { + const settingsPath = resolveEarlyDesktopSettingsPath(input); + try { + const parsed = decodeEarlyDesktopSettingsJson(input.readFileString(settingsPath)); + return normalizeLinuxPasswordStorePreference(parsed.linuxPasswordStore); + } catch { + return DEFAULT_LINUX_PASSWORD_STORE; + } +} + +export function resolveEarlyLinuxElectronOptions( + input: EarlyLinuxElectronOptionsInput, +): EarlyLinuxElectronOptions { + const preference = resolveEarlyLinuxPasswordStorePreference(input); + return { + linuxWmClass: isDevelopmentEnvironment(input.env) ? "t3code-dev" : "t3code", + passwordStore: resolveLinuxPasswordStoreSwitch({ + preference, + env: input.env, + }), + }; +} diff --git a/apps/desktop/src/app/DesktopEnvironment.ts b/apps/desktop/src/app/DesktopEnvironment.ts index f07d1c4cf..9190acdb1 100644 --- a/apps/desktop/src/app/DesktopEnvironment.ts +++ b/apps/desktop/src/app/DesktopEnvironment.ts @@ -14,6 +14,7 @@ import * as Path from "effect/Path"; import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts"; import * as DesktopConfig from "./DesktopConfig.ts"; +import { resolveDesktopBaseDir, resolveDesktopStateDir } from "./DesktopStatePaths.ts"; import { isNightlyDesktopVersion } from "../updates/updateChannels.ts"; export interface MakeDesktopEnvironmentInput { @@ -67,6 +68,8 @@ export class DesktopEnvironment extends Context.Service< readonly appUserModelId: string; readonly linuxDesktopEntryName: string; readonly linuxWmClass: string; + readonly linuxApplicationsDir: string; + readonly appImagePath: Option.Option; readonly userDataDirName: string; readonly legacyUserDataDirName: string; readonly defaultDesktopSettings: DesktopAppSettings.DesktopSettings; @@ -148,8 +151,11 @@ const make = Effect.fn("desktop.environment.make")(function* ( : input.platform === "darwin" ? path.join(homeDirectory, "Library", "Application Support") : Option.getOrElse(config.xdgConfigHome, () => path.join(homeDirectory, ".config")); - const configuredBaseDir = config.t3Home; - const baseDir = Option.getOrElse(configuredBaseDir, () => path.join(homeDirectory, ".t3")); + const baseDir = resolveDesktopBaseDir({ + homeDirectory, + joinPath: path.join, + t3Home: config.t3Home, + }); const rootDir = path.resolve(input.dirname, "../../.."); const appRoot = input.isPackaged ? input.appPath : rootDir; const branding = resolveDesktopAppBranding({ @@ -157,12 +163,18 @@ const make = Effect.fn("desktop.environment.make")(function* ( appVersion: input.appVersion, }); const displayName = branding.displayName; - const stateDir = path.join( + const stateDir = resolveDesktopStateDir({ baseDir, - isDevelopment && Option.isNone(configuredBaseDir) ? "dev" : "userdata", - ); + isDevelopment, + joinPath: path.join, + t3Home: config.t3Home, + }); const userDataDirName = isDevelopment ? "t3code-dev" : "t3code"; const legacyUserDataDirName = isDevelopment ? "T3 Code (Dev)" : "T3 Code (Alpha)"; + const linuxApplicationsDir = path.join( + Option.getOrElse(config.xdgDataHome, () => path.join(homeDirectory, ".local", "share")), + "applications", + ); const resourcesPath = input.resourcesPath; return DesktopEnvironment.of({ @@ -206,6 +218,8 @@ const make = Effect.fn("desktop.environment.make")(function* ( ), linuxDesktopEntryName: isDevelopment ? "t3code-dev.desktop" : "t3code.desktop", linuxWmClass: isDevelopment ? "t3code-dev" : "t3code", + linuxApplicationsDir, + appImagePath: config.appImagePath, userDataDirName, legacyUserDataDirName, defaultDesktopSettings: DesktopAppSettings.resolveDefaultDesktopSettings(input.appVersion), diff --git a/apps/desktop/src/app/DesktopLifecycle.test.ts b/apps/desktop/src/app/DesktopLifecycle.test.ts index 978e000a7..be9d7f345 100644 --- a/apps/desktop/src/app/DesktopLifecycle.test.ts +++ b/apps/desktop/src/app/DesktopLifecycle.test.ts @@ -35,6 +35,7 @@ describe("DesktopLifecycle", () => { setDesktopName: () => Effect.void, setDockIcon: () => Effect.void, appendCommandLineSwitch: () => Effect.void, + removeCommandLineSwitch: () => Effect.void, onBeforeQuitForUpdate: (listener) => Effect.acquireRelease( Effect.sync(() => { diff --git a/apps/desktop/src/app/DesktopLinuxUrlHandler.test.ts b/apps/desktop/src/app/DesktopLinuxUrlHandler.test.ts new file mode 100644 index 000000000..30183808a --- /dev/null +++ b/apps/desktop/src/app/DesktopLinuxUrlHandler.test.ts @@ -0,0 +1,229 @@ +import { assert, describe, it } from "@effect/vitest"; +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 PlatformError from "effect/PlatformError"; +import * as Sink from "effect/Sink"; +import * as Stream from "effect/Stream"; +import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; + +import * as DesktopEnvironment from "./DesktopEnvironment.ts"; +import * as DesktopLinuxUrlHandler from "./DesktopLinuxUrlHandler.ts"; + +interface RecordedRegistration { + readonly directories: string[]; + readonly files: Array<{ readonly path: string; readonly content: string }>; + readonly commands: Array<{ readonly command: string; readonly args: ReadonlyArray }>; +} + +const makeEnvironment = (overrides: Record = {}) => + DesktopEnvironment.DesktopEnvironment.of({ + platform: "linux", + isPackaged: true, + isDevelopment: false, + displayName: "T3 Code (Alpha)", + linuxWmClass: "t3code", + linuxApplicationsDir: "/home/alice/.local/share/applications", + appImagePath: Option.some("/home/alice/Applications/T3-Code.AppImage"), + path: { join: (...parts: ReadonlyArray) => parts.join("/") }, + ...overrides, + } as unknown as DesktopEnvironment.DesktopEnvironment["Service"]); + +const mockProcess = (exitCode: number) => + ChildProcessSpawner.makeHandle({ + pid: ChildProcessSpawner.ProcessId(1), + exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(exitCode)), + isRunning: Effect.succeed(false), + kill: () => Effect.void, + unref: Effect.succeed(Effect.void), + stdin: Sink.drain, + stdout: Stream.empty, + stderr: Stream.empty, + all: Stream.empty, + getInputFd: () => Sink.drain, + getOutputFd: () => Stream.empty, + }); + +const makeHandlerLayer = ( + recorded: RecordedRegistration, + input: { + readonly environment?: Record; + readonly xdgMimeExitCode?: number; + readonly writeError?: PlatformError.PlatformError; + } = {}, +) => + DesktopLinuxUrlHandler.layer.pipe( + Layer.provide( + Layer.mergeAll( + Layer.succeed(DesktopEnvironment.DesktopEnvironment, makeEnvironment(input.environment)), + FileSystem.layerNoop({ + makeDirectory: (path) => + Effect.sync(() => { + recorded.directories.push(path); + }), + writeFileString: (path, content) => + input.writeError + ? Effect.fail(input.writeError) + : Effect.sync(() => { + recorded.files.push({ path, content }); + }), + }), + Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make((command) => { + const childProcess = command as unknown as { + readonly command: string; + readonly args: ReadonlyArray; + }; + recorded.commands.push({ + command: childProcess.command, + args: childProcess.args, + }); + return Effect.succeed(mockProcess(input.xdgMimeExitCode ?? 0)); + }), + ), + ), + ), + ); + +const runRegister = ( + recorded: RecordedRegistration, + input: Parameters[1] = {}, +) => + Effect.gen(function* () { + const handler = yield* DesktopLinuxUrlHandler.DesktopLinuxUrlHandler; + yield* handler.register; + }).pipe(Effect.provide(makeHandlerLayer(recorded, input))); + +const emptyRecording = (): RecordedRegistration => ({ + directories: [], + files: [], + commands: [], +}); + +describe("DesktopLinuxUrlHandler", () => { + it("renders a scheme-handler desktop entry with freedesktop Exec quoting", () => { + const entry = DesktopLinuxUrlHandler.renderUrlHandlerDesktopEntry({ + displayName: "T3 Code (Nightly)", + execTarget: '/home/al ice/Apps/T3 "100%" $HOME\\x.AppImage', + scheme: "t3code", + }); + + assert.include(entry, "[Desktop Entry]"); + assert.include(entry, "Name=T3 Code (Nightly)"); + // Exec composes both escaping layers: a literal backslash becomes four + // backslashes in the file, a quote three characters, a dollar sign two + // backslashes plus the sign. + assert.include( + entry, + 'Exec="/home/al ice/Apps/T3 \\\\"100%%\\\\" \\\\$HOME\\\\\\\\x.AppImage" %U', + ); + assert.include(entry, "NoDisplay=true"); + assert.notInclude(entry, "StartupWMClass="); + assert.include(entry, "MimeType=x-scheme-handler/t3code;"); + }); + + it("carries structured context on registration errors", () => { + const writeError = new DesktopLinuxUrlHandler.DesktopLinuxUrlHandlerRegistrationError({ + step: "write-desktop-entry", + scheme: "t3code", + desktopEntryPath: "/home/alice/.local/share/applications/t3code-url-handler.desktop", + cause: new Error("boom"), + }); + assert.equal( + writeError.message, + "Failed to register the t3code:// URL handler (step: write-desktop-entry).", + ); + assert.equal( + writeError.desktopEntryPath, + "/home/alice/.local/share/applications/t3code-url-handler.desktop", + ); + + const exitError = new DesktopLinuxUrlHandler.DesktopLinuxUrlHandlerRegistrationError({ + step: "set-default-handler", + scheme: "t3code", + exitCode: 4, + }); + assert.equal( + exitError.message, + "Failed to register the t3code:// URL handler (step: set-default-handler, xdg-mime exit code 4).", + ); + }); + + it.effect("writes the handler entry and claims the scheme default via xdg-mime", () => { + const recorded = emptyRecording(); + + return Effect.gen(function* () { + yield* runRegister(recorded); + + assert.deepEqual(recorded.directories, ["/home/alice/.local/share/applications"]); + assert.equal(recorded.files.length, 1); + assert.equal( + recorded.files[0]?.path, + "/home/alice/.local/share/applications/t3code-url-handler.desktop", + ); + assert.include( + recorded.files[0]?.content, + 'Exec="/home/alice/Applications/T3-Code.AppImage" %U', + ); + assert.include(recorded.files[0]?.content, "MimeType=x-scheme-handler/t3code;"); + assert.deepEqual(recorded.commands, [ + { + command: "xdg-mime", + args: ["default", "t3code-url-handler.desktop", "x-scheme-handler/t3code"], + }, + ]); + }); + }); + + it.effect("falls back to the process executable outside an AppImage", () => { + const recorded = emptyRecording(); + + return Effect.gen(function* () { + yield* runRegister(recorded, { environment: { appImagePath: Option.none() } }); + + assert.include( + recorded.files[0]?.content, + `Exec=${DesktopLinuxUrlHandler.escapeDesktopEntryExecArgument(process.execPath)} %U`, + ); + }); + }); + + it.effect("does nothing on other platforms or unpackaged builds", () => { + const nonLinux = emptyRecording(); + const unpackaged = emptyRecording(); + + return Effect.gen(function* () { + yield* runRegister(nonLinux, { environment: { platform: "darwin" } }); + yield* runRegister(unpackaged, { environment: { isPackaged: false } }); + + for (const recorded of [nonLinux, unpackaged]) { + assert.deepEqual(recorded.directories, []); + assert.deepEqual(recorded.files, []); + assert.deepEqual(recorded.commands, []); + } + }); + }); + + it.effect("never fails startup when registration cannot complete", () => { + const xdgMimeFailed = emptyRecording(); + const writeFailed = emptyRecording(); + + return Effect.gen(function* () { + yield* runRegister(xdgMimeFailed, { xdgMimeExitCode: 1 }); + yield* runRegister(writeFailed, { + writeError: PlatformError.systemError({ + _tag: "PermissionDenied", + module: "FileSystem", + method: "writeFileString", + description: "read-only filesystem", + pathOrDescriptor: "/home/alice/.local/share/applications/t3code-url-handler.desktop", + }), + }); + + assert.equal(xdgMimeFailed.files.length, 1); + assert.deepEqual(writeFailed.commands, []); + }); + }); +}); diff --git a/apps/desktop/src/app/DesktopLinuxUrlHandler.ts b/apps/desktop/src/app/DesktopLinuxUrlHandler.ts new file mode 100644 index 000000000..e531a54df --- /dev/null +++ b/apps/desktop/src/app/DesktopLinuxUrlHandler.ts @@ -0,0 +1,191 @@ +import * as Context from "effect/Context"; +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 Schema from "effect/Schema"; +import * as ChildProcess from "effect/unstable/process/ChildProcess"; +import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; + +import * as ElectronProtocol from "../electron/ElectronProtocol.ts"; +import * as DesktopEnvironment from "./DesktopEnvironment.ts"; +import { makeComponentLogger } from "./DesktopObservability.ts"; + +// Linux ships as an AppImage, so the .desktop entry users end up with is +// created by whatever integration tool they use (AppImageLauncher names it +// appimagekit_-….desktop) and its filename is not under our control. +// Electron's app.setAsDefaultProtocolClient resolves the desktop id from +// setDesktopName, which cannot match those files — so the browser keeps +// prompting "Choose an application" for every OAuth callback. Instead, write +// our own handler entry pointing at the current AppImage and claim the +// scheme default via xdg-mime, exactly what the file manager's "set as +// default" checkbox would record in mimeapps.list. +export const URL_HANDLER_DESKTOP_ENTRY_NAME = "t3code-url-handler.desktop"; + +const { logInfo, logWarning } = makeComponentLogger("desktop-linux-url-handler"); + +export class DesktopLinuxUrlHandlerRegistrationError extends Schema.TaggedErrorClass()( + "DesktopLinuxUrlHandlerRegistrationError", + { + step: Schema.Literals(["write-desktop-entry", "set-default-handler"]), + scheme: Schema.String, + desktopEntryPath: Schema.optionalKey(Schema.String), + exitCode: Schema.optionalKey(Schema.Number), + cause: Schema.optionalKey(Schema.Defect()), + }, +) { + override get message(): string { + const exitCode = this.exitCode === undefined ? "" : `, xdg-mime exit code ${this.exitCode}`; + return `Failed to register the ${this.scheme}:// URL handler (step: ${this.step}${exitCode}).`; + } +} + +const isRegistrationError = Schema.is(DesktopLinuxUrlHandlerRegistrationError); + +const escapeDesktopEntryString = (value: string): string => + value + .replaceAll("\\", "\\\\") + .replaceAll("\n", "\\n") + .replaceAll("\r", "\\r") + .replaceAll("\t", "\\t"); + +// Exec values are unescaped twice by implementations: first the general +// string-value rules, then the Exec quoting rules — so writing composes the +// layers in reverse. The argument is double-quoted with reserved characters +// backslash-escaped and literal percent signs doubled (field codes), and the +// general string escaping is applied on top: a literal backslash ends up as +// four backslashes in the file, a quote as \\", a dollar sign as \\$. +export function escapeDesktopEntryExecArgument(value: string): string { + const quoted = value + .replaceAll("\\", () => "\\\\") + .replaceAll("`", () => "\\`") + .replaceAll("$", () => "\\$") + .replaceAll('"', () => '\\"') + .replaceAll("%", () => "%%"); + return escapeDesktopEntryString(`"${quoted}"`); +} + +// The AppImage integration entry owns the window identity and icon. This +// hidden URL-only entry must not compete with it for StartupWMClass matching. +export function renderUrlHandlerDesktopEntry(input: { + readonly displayName: string; + readonly execTarget: string; + readonly scheme: string; +}): string { + return [ + "[Desktop Entry]", + "Type=Application", + `Name=${escapeDesktopEntryString(input.displayName)}`, + `Exec=${escapeDesktopEntryExecArgument(input.execTarget)} %U`, + "Terminal=false", + "NoDisplay=true", + "StartupNotify=false", + `MimeType=x-scheme-handler/${input.scheme};`, + "", + ].join("\n"); +} + +export class DesktopLinuxUrlHandler extends Context.Service< + DesktopLinuxUrlHandler, + { + readonly register: Effect.Effect; + } +>()("@t3tools/desktop/app/DesktopLinuxUrlHandler") {} + +export const make = Effect.gen(function* () { + const environment = yield* DesktopEnvironment.DesktopEnvironment; + const fileSystem = yield* FileSystem.FileSystem; + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + + const scheme = ElectronProtocol.getDesktopScheme(environment.isDevelopment); + const desktopEntryPath = environment.path.join( + environment.linuxApplicationsDir, + URL_HANDLER_DESKTOP_ENTRY_NAME, + ); + + const writeDesktopEntry = Effect.gen(function* () { + // Inside the mounted AppImage, process.execPath points at a transient + // /tmp/.mount_* path — the handler must launch the AppImage itself. + const execTarget = Option.getOrElse(environment.appImagePath, () => process.execPath); + yield* fileSystem.makeDirectory(environment.linuxApplicationsDir, { recursive: true }); + yield* fileSystem.writeFileString( + desktopEntryPath, + renderUrlHandlerDesktopEntry({ + displayName: environment.displayName, + execTarget, + scheme, + }), + ); + }).pipe( + Effect.mapError( + (cause) => + new DesktopLinuxUrlHandlerRegistrationError({ + step: "write-desktop-entry", + scheme, + desktopEntryPath, + cause, + }), + ), + ); + + const setDefaultHandler = Effect.scoped( + Effect.gen(function* () { + const command = ChildProcess.make( + "xdg-mime", + ["default", URL_HANDLER_DESKTOP_ENTRY_NAME, `x-scheme-handler/${scheme}`], + { + stdin: "ignore", + stdout: "ignore", + stderr: "ignore", + }, + ); + const handle = yield* spawner.spawn(command); + const exitCode = yield* handle.exitCode; + if ((exitCode as unknown as number) !== 0) { + return yield* new DesktopLinuxUrlHandlerRegistrationError({ + step: "set-default-handler", + scheme, + exitCode: Number(exitCode), + }); + } + }), + ).pipe( + Effect.mapError((error) => + isRegistrationError(error) + ? error + : new DesktopLinuxUrlHandlerRegistrationError({ + step: "set-default-handler", + scheme, + cause: error, + }), + ), + ); + + const register = Effect.gen(function* () { + if (environment.platform !== "linux" || !environment.isPackaged) { + return; + } + yield* writeDesktopEntry; + yield* setDefaultHandler; + yield* logInfo("registered URL scheme handler", { scheme }); + }).pipe( + // Registration is best-effort: a missing xdg-mime or read-only home must + // never block startup — the OS chooser remains as fallback. + Effect.catch((error) => + logWarning("URL scheme handler registration failed", { + scheme, + step: error.step, + message: error.message, + ...(error.desktopEntryPath === undefined + ? {} + : { desktopEntryPath: error.desktopEntryPath }), + ...(error.exitCode === undefined ? {} : { exitCode: error.exitCode }), + }), + ), + Effect.withSpan("desktop.linuxUrlHandler.register"), + ); + + return DesktopLinuxUrlHandler.of({ register }); +}); + +export const layer = Layer.effect(DesktopLinuxUrlHandler, make); diff --git a/apps/desktop/src/app/DesktopPreReadyPlatform.test.ts b/apps/desktop/src/app/DesktopPreReadyPlatform.test.ts new file mode 100644 index 000000000..a29e0fd3b --- /dev/null +++ b/apps/desktop/src/app/DesktopPreReadyPlatform.test.ts @@ -0,0 +1,130 @@ +import { assert, describe, it } from "@effect/vitest"; +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import { beforeEach, vi } from "vite-plus/test"; + +const { appendSwitchMock, getSwitchValueMock, hasSwitchMock, registerSchemesMock } = vi.hoisted( + () => ({ + appendSwitchMock: vi.fn(), + getSwitchValueMock: vi.fn(), + hasSwitchMock: vi.fn(), + registerSchemesMock: vi.fn(), + }), +); + +vi.mock("electron", () => ({ + app: { + commandLine: { + appendSwitch: appendSwitchMock, + getSwitchValue: getSwitchValueMock, + hasSwitch: hasSwitchMock, + }, + }, + protocol: { + registerSchemesAsPrivileged: registerSchemesMock, + }, +})); + +import * as DesktopPreReadyPlatform from "./DesktopPreReadyPlatform.ts"; + +describe("DesktopPreReadyPlatform", () => { + beforeEach(() => { + appendSwitchMock.mockReset(); + getSwitchValueMock.mockReset(); + hasSwitchMock.mockReset(); + registerSchemesMock.mockReset(); + }); + + it("reads an explicit Electron command-line switch value", () => { + const value = DesktopPreReadyPlatform.readCommandLineSwitchValue( + { + hasSwitch: (switchName) => switchName === "password-store", + getSwitchValue: (switchName) => { + assert.equal(switchName, "password-store"); + return "basic"; + }, + }, + "password-store", + ); + + assert.equal(value, "basic"); + }); + + it("treats valueless Electron command-line switches as absent", () => { + const value = DesktopPreReadyPlatform.readCommandLineSwitchValue( + { + hasSwitch: () => true, + getSwitchValue: () => "", + }, + "password-store", + ); + + assert.isNull(value); + }); + + it("returns null for missing Electron command-line switches", () => { + const value = DesktopPreReadyPlatform.readCommandLineSwitchValue( + { + hasSwitch: () => false, + getSwitchValue: () => { + throw new Error("Unexpected switch value read."); + }, + }, + "password-store", + ); + + assert.isNull(value); + }); + + it.effect( + "acquires a synchronous pre-ready layer before an asynchronous Clerk-shaped layer", + () => + Effect.gen(function* () { + class ClerkShaped extends Context.Service()( + "@t3tools/desktop/app/DesktopPreReadyPlatform.test/ClerkShaped", + ) {} + + const events: Array = []; + registerSchemesMock.mockImplementation(() => { + events.push("pre-ready"); + }); + + const preReadyLayer = DesktopPreReadyPlatform.layer.pipe( + Layer.provide(Layer.succeed(HostProcessPlatform, "darwin")), + ); + + const clerkShapedLayer = Layer.effect( + ClerkShaped, + Effect.promise(() => Promise.resolve()).pipe( + Effect.map(() => { + events.push("clerk"); + return { ready: true as const }; + }), + ), + ); + + const runtimeLayer = clerkShapedLayer.pipe( + Layer.flatMap((clerkContext) => Layer.succeedContext(clerkContext)), + Layer.provideMerge(preReadyLayer), + ); + + const result = yield* Effect.all({ + clerk: ClerkShaped, + preReady: DesktopPreReadyPlatform.DesktopPreReadyElectronOptions, + }).pipe(Effect.provide(runtimeLayer)); + + assert.deepEqual(result, { + clerk: { ready: true }, + preReady: { + linux: null, + linuxPasswordStoreCommandLine: null, + }, + }); + assert.deepEqual(events, ["pre-ready", "clerk"]); + assert.equal(registerSchemesMock.mock.calls.length, 1); + assert.equal(appendSwitchMock.mock.calls.length, 0); + }), + ); +}); diff --git a/apps/desktop/src/app/DesktopPreReadyPlatform.ts b/apps/desktop/src/app/DesktopPreReadyPlatform.ts new file mode 100644 index 000000000..7d145632d --- /dev/null +++ b/apps/desktop/src/app/DesktopPreReadyPlatform.ts @@ -0,0 +1,74 @@ +// @effect-diagnostics nodeBuiltinImport:off - pre-ready Electron setup reads persisted settings synchronously before app services are available. +import * as NodeFS from "node:fs"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; + +import * as Electron from "electron"; +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; + +import * as DesktopEarlyElectronStartup from "./DesktopEarlyElectronStartup.ts"; +import * as ElectronProtocol from "../electron/ElectronProtocol.ts"; + +export interface DesktopPreReadyCommandLineReader { + readonly hasSwitch: (switchName: string) => boolean; + readonly getSwitchValue: (switchName: string) => string; +} + +export function readCommandLineSwitchValue( + commandLine: DesktopPreReadyCommandLineReader, + switchName: string, +): string | null { + if (!commandLine.hasSwitch(switchName)) { + return null; + } + + const value = commandLine.getSwitchValue(switchName).trim(); + return value.length > 0 ? value : null; +} + +export const resolveEarlyLinuxElectronOptionsFromProcess = + (): DesktopEarlyElectronStartup.EarlyLinuxElectronOptions => + DesktopEarlyElectronStartup.resolveEarlyLinuxElectronOptions({ + env: process.env, + homeDirectory: NodeOS.homedir(), + joinPath: NodePath.posix.join, + readFileString: (path) => NodeFS.readFileSync(path, "utf8"), + }); + +export class DesktopPreReadyElectronOptions extends Context.Service< + DesktopPreReadyElectronOptions, + { + readonly linux: DesktopEarlyElectronStartup.EarlyLinuxElectronOptions | null; + readonly linuxPasswordStoreCommandLine: string | null; + } +>()("@t3tools/desktop/app/DesktopPreReadyPlatform/DesktopPreReadyElectronOptions") {} + +export const make = Effect.gen(function* () { + const platform = yield* HostProcessPlatform; + return yield* Effect.sync((): DesktopPreReadyElectronOptions["Service"] => { + const linuxPasswordStoreCommandLine = + platform === "linux" + ? readCommandLineSwitchValue(Electron.app.commandLine, "password-store") + : null; + const linux = platform === "linux" ? resolveEarlyLinuxElectronOptionsFromProcess() : null; + + if (linux !== null) { + Electron.app.commandLine.appendSwitch("class", linux.linuxWmClass); + if (linux.passwordStore !== null && linuxPasswordStoreCommandLine === null) { + Electron.app.commandLine.appendSwitch("password-store", linux.passwordStore); + } + } + + return { linux, linuxPasswordStoreCommandLine }; + }); +}).pipe(Effect.withSpan("desktop.electron.configureBeforeReady")); + +// Keep Electron's strict pre-ready setup isolated so later runtime layers cannot +// observe app readiness before scheme privileges and command-line switches exist. +export const layer = Layer.mergeAll( + ElectronProtocol.layerSchemePrivileges, + Layer.effect(DesktopPreReadyElectronOptions, make), +); diff --git a/apps/desktop/src/app/DesktopStatePaths.ts b/apps/desktop/src/app/DesktopStatePaths.ts new file mode 100644 index 000000000..006dd9709 --- /dev/null +++ b/apps/desktop/src/app/DesktopStatePaths.ts @@ -0,0 +1,32 @@ +import * as Option from "effect/Option"; + +export type JoinPath = (first: string, ...segments: string[]) => string; + +function normalizeConfiguredBaseDir(t3Home: Option.Option): Option.Option { + if (Option.isNone(t3Home)) { + return Option.none(); + } + const trimmed = t3Home.value.trim(); + return trimmed.length > 0 ? Option.some(trimmed) : Option.none(); +} + +export function resolveDesktopBaseDir(input: { + readonly homeDirectory: string; + readonly joinPath: JoinPath; + readonly t3Home: Option.Option; +}): string { + return Option.getOrElse(normalizeConfiguredBaseDir(input.t3Home), () => + input.joinPath(input.homeDirectory, ".t3"), + ); +} + +export function resolveDesktopStateDir(input: { + readonly baseDir: string; + readonly isDevelopment: boolean; + readonly joinPath: JoinPath; + readonly t3Home: Option.Option; +}): string { + const useDevSubdir = + input.isDevelopment && Option.isNone(normalizeConfiguredBaseDir(input.t3Home)); + return input.joinPath(input.baseDir, useDevSubdir ? "dev" : "userdata"); +} diff --git a/apps/desktop/src/electron/ElectronApp.test.ts b/apps/desktop/src/electron/ElectronApp.test.ts index ac14f56ad..e0d229497 100644 --- a/apps/desktop/src/electron/ElectronApp.test.ts +++ b/apps/desktop/src/electron/ElectronApp.test.ts @@ -14,6 +14,7 @@ const { quitMock, relaunchMock, removeListenerMock, + removeSwitchMock, setAboutPanelOptionsMock, setAppUserModelIdMock, setAsDefaultProtocolClientMock, @@ -34,6 +35,7 @@ const { quitMock: vi.fn(), relaunchMock: vi.fn(), removeListenerMock: vi.fn(), + removeSwitchMock: vi.fn(), setAboutPanelOptionsMock: vi.fn(), setAppUserModelIdMock: vi.fn(), setAsDefaultProtocolClientMock: vi.fn(() => true), @@ -52,6 +54,7 @@ vi.mock("electron", () => ({ app: { commandLine: { appendSwitch: appendSwitchMock, + removeSwitch: removeSwitchMock, }, dock: { setIcon: setDockIconMock, @@ -89,6 +92,7 @@ describe("ElectronApp", () => { quitMock.mockClear(); relaunchMock.mockClear(); removeListenerMock.mockClear(); + removeSwitchMock.mockClear(); setPathMock.mockClear(); }); @@ -178,4 +182,13 @@ describe("ElectronApp", () => { ]); }).pipe(Effect.provide(ElectronApp.layer)), ); + + it.effect("removes command-line switches through the service", () => + Effect.gen(function* () { + const electronApp = yield* ElectronApp.ElectronApp; + yield* electronApp.removeCommandLineSwitch("password-store"); + + assert.deepEqual(removeSwitchMock.mock.calls, [["password-store"]]); + }).pipe(Effect.provide(ElectronApp.layer)), + ); }); diff --git a/apps/desktop/src/electron/ElectronApp.ts b/apps/desktop/src/electron/ElectronApp.ts index 733236171..6fb84c53b 100644 --- a/apps/desktop/src/electron/ElectronApp.ts +++ b/apps/desktop/src/electron/ElectronApp.ts @@ -69,6 +69,7 @@ export class ElectronApp extends Context.Service< readonly onBeforeQuitForUpdate: ( listener: () => void, ) => Effect.Effect; + readonly removeCommandLineSwitch: (switchName: string) => Effect.Effect; readonly on: >( eventName: string, listener: (...args: Args) => void, @@ -191,6 +192,10 @@ export const make = ElectronApp.of({ Electron.autoUpdater.removeListener("before-quit-for-update", listener); }), ).pipe(Effect.asVoid), + removeCommandLineSwitch: (switchName) => + Effect.sync(() => { + Electron.app.commandLine.removeSwitch(switchName); + }), on: addScopedAppListener, }); diff --git a/apps/desktop/src/electron/ElectronProtocol.ts b/apps/desktop/src/electron/ElectronProtocol.ts index 9d5a47806..11459c9ef 100644 --- a/apps/desktop/src/electron/ElectronProtocol.ts +++ b/apps/desktop/src/electron/ElectronProtocol.ts @@ -105,6 +105,38 @@ function withContentSecurityPolicy(response: Response, policy: string): Response }); } +/** + * Must run synchronously during process bootstrap, before Electron emits `ready`. + */ +export function registerDesktopSchemePrivilegesSync(): void { + Electron.protocol.registerSchemesAsPrivileged([ + { + scheme: DESKTOP_PRODUCTION_SCHEME, + privileges: { + standard: true, + secure: true, + supportFetchAPI: true, + corsEnabled: true, + }, + }, + { + scheme: DESKTOP_DEVELOPMENT_SCHEME, + privileges: { + standard: true, + secure: true, + supportFetchAPI: true, + corsEnabled: true, + }, + }, + ]); +} + +const registerDesktopSchemePrivileges = Effect.sync(registerDesktopSchemePrivilegesSync).pipe( + Effect.withSpan("desktop.electron.protocol.registerSchemePrivileges"), +); + +export const layerSchemePrivileges = Layer.effectDiscard(registerDesktopSchemePrivileges); + async function proxyRequest( request: Request, targetOrigin: URL, diff --git a/apps/desktop/src/electron/ElectronSafeStorage.ts b/apps/desktop/src/electron/ElectronSafeStorage.ts index 76162c164..b9dab7105 100644 --- a/apps/desktop/src/electron/ElectronSafeStorage.ts +++ b/apps/desktop/src/electron/ElectronSafeStorage.ts @@ -1,9 +1,11 @@ import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; import * as Electron from "electron"; +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; const electronSafeStorageErrorFields = { cause: Schema.Defect(), @@ -60,24 +62,39 @@ export class ElectronSafeStorage extends Context.Service< readonly decryptString: ( value: Uint8Array, ) => Effect.Effect; + readonly selectedStorageBackend: Effect.Effect>; } >()("@t3tools/desktop/electron/ElectronSafeStorage") {} -export const make = ElectronSafeStorage.of({ - isEncryptionAvailable: Effect.try({ - try: () => Electron.safeStorage.isEncryptionAvailable(), - catch: (cause) => new ElectronSafeStorageAvailabilityError({ cause }), - }), - encryptString: (value) => - Effect.try({ - try: () => Electron.safeStorage.encryptString(value), - catch: (cause) => new ElectronSafeStorageEncryptError({ cause }), +export const make = Effect.gen(function* () { + const platform = yield* HostProcessPlatform; + + return ElectronSafeStorage.of({ + isEncryptionAvailable: Effect.try({ + try: () => Electron.safeStorage.isEncryptionAvailable(), + catch: (cause) => new ElectronSafeStorageAvailabilityError({ cause }), }), - decryptString: (value) => - Effect.try({ - try: () => Electron.safeStorage.decryptString(Buffer.from(value)), - catch: (cause) => new ElectronSafeStorageDecryptError({ cause }), + encryptString: (value) => + Effect.try({ + try: () => Electron.safeStorage.encryptString(value), + catch: (cause) => new ElectronSafeStorageEncryptError({ cause }), + }), + decryptString: (value) => + Effect.try({ + try: () => Electron.safeStorage.decryptString(Buffer.from(value)), + catch: (cause) => new ElectronSafeStorageDecryptError({ cause }), + }), + selectedStorageBackend: Effect.sync(() => { + if (platform !== "linux") { + return Option.none(); + } + try { + return Option.fromNullishOr(Electron.safeStorage.getSelectedStorageBackend()); + } catch { + return Option.none(); + } }), + }); }); -export const layer = Layer.succeed(ElectronSafeStorage, make); +export const layer = Layer.effect(ElectronSafeStorage, make); diff --git a/apps/desktop/src/linuxSecretStorage.test.ts b/apps/desktop/src/linuxSecretStorage.test.ts new file mode 100644 index 000000000..a91790200 --- /dev/null +++ b/apps/desktop/src/linuxSecretStorage.test.ts @@ -0,0 +1,203 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { + normalizeLinuxPasswordStorePreference, + resolveLinuxPasswordStoreSwitch, + resolveLinuxSecretStorageUnavailableMessage, +} from "./linuxSecretStorage.ts"; + +const autoSwitch = (env: NodeJS.ProcessEnv) => + resolveLinuxPasswordStoreSwitch({ preference: "auto", env }); + +describe("linuxSecretStorage", () => { + it("preserves explicit supported password-store preferences", () => { + expect(normalizeLinuxPasswordStorePreference("gnome-libsecret")).toBe("gnome-libsecret"); + expect(normalizeLinuxPasswordStorePreference("kwallet")).toBe("kwallet"); + expect(normalizeLinuxPasswordStorePreference("kwallet5")).toBe("kwallet5"); + expect(normalizeLinuxPasswordStorePreference("kwallet6")).toBe("kwallet6"); + }); + + it("falls back to auto for missing or unsupported preferences", () => { + expect(normalizeLinuxPasswordStorePreference(undefined)).toBe("auto"); + expect(normalizeLinuxPasswordStorePreference("basic")).toBe("auto"); + }); + + it("uses explicit preferences instead of the auto heuristic", () => { + for (const preference of ["gnome-libsecret", "kwallet", "kwallet5", "kwallet6"] as const) { + expect( + resolveLinuxPasswordStoreSwitch({ preference, env: { XDG_CURRENT_DESKTOP: "niri" } }), + ).toBe(preference); + // An explicit preference also wins where auto would have stayed out of the way. + expect( + resolveLinuxPasswordStoreSwitch({ preference, env: { XDG_CURRENT_DESKTOP: "KDE" } }), + ).toBe(preference); + } + }); + + it("leaves canonical KDE sessions to Electron's own wallet selection", () => { + expect(autoSwitch({ XDG_CURRENT_DESKTOP: "KDE" })).toBeNull(); + expect(autoSwitch({ XDG_CURRENT_DESKTOP: "KDE", KDE_SESSION_VERSION: "6" })).toBeNull(); + expect(autoSwitch({ XDG_CURRENT_DESKTOP: "KDE", KDE_SESSION_VERSION: "5" })).toBeNull(); + expect(autoSwitch({ XDG_CURRENT_DESKTOP: "KDE:plasma" })).toBeNull(); + }); + + it("does not force a password-store for desktops Electron already recognizes", () => { + expect(autoSwitch({ XDG_CURRENT_DESKTOP: "GNOME" })).toBeNull(); + for (const desktop of ["Deepin", "Pantheon", "UKUI", "Unity", "X-Cinnamon", "XFCE"]) { + expect(autoSwitch({ XDG_CURRENT_DESKTOP: desktop })).toBeNull(); + } + }); + + it("recognizes a known desktop later in a colon-separated XDG_CURRENT_DESKTOP list", () => { + expect(autoSwitch({ XDG_CURRENT_DESKTOP: "niri:GNOME" })).toBeNull(); + expect(autoSwitch({ XDG_CURRENT_DESKTOP: "ubuntu:GNOME" })).toBeNull(); + }); + + it("forces gnome-libsecret for unrecognized Linux desktop sessions", () => { + expect(autoSwitch({ XDG_CURRENT_DESKTOP: "niri" })).toBe("gnome-libsecret"); + expect(autoSwitch({ XDG_CURRENT_DESKTOP: "Hyprland" })).toBe("gnome-libsecret"); + expect(autoSwitch({})).toBe("gnome-libsecret"); + }); + + it("forces gnome-libsecret for desktops Electron recognizes but leaves on basic text", () => { + expect(autoSwitch({ XDG_CURRENT_DESKTOP: "LXQt" })).toBe("gnome-libsecret"); + // Chromium stops at the first recognized value, so a later name cannot rescue the session. + expect(autoSwitch({ XDG_CURRENT_DESKTOP: "LXQt:GNOME" })).toBe("gnome-libsecret"); + expect(autoSwitch({ XDG_CURRENT_DESKTOP: "LXQt:KDE" })).toBe("gnome-libsecret"); + expect(autoSwitch({ XDG_CURRENT_DESKTOP: "LXQt:plasma" })).toBe("gnome-libsecret"); + }); + + it("does not treat lowercase desktop names as ones Electron recognizes", () => { + // Chromium matches these case-sensitively, so lowercase spellings reach basic text. + expect(autoSwitch({ XDG_CURRENT_DESKTOP: "gnome" })).toBe("gnome-libsecret"); + expect(autoSwitch({ XDG_CURRENT_DESKTOP: "xfce" })).toBe("gnome-libsecret"); + expect(autoSwitch({ XDG_CURRENT_DESKTOP: "kde", KDE_SESSION_VERSION: "6" })).toBe( + "gnome-libsecret", + ); + }); + + it("overrides KDE sessions identified only by legacy variables", () => { + // Chromium reaches KWallet4 for some of these, such as DESKTOP_SESSION=kde with a version, and + // basic text for the rest. Either way these are the variables a previous session leaves behind, + // so they are treated as unproven and forced to a real keyring rather than a guessed wallet. + expect(autoSwitch({ XDG_CURRENT_DESKTOP: "plasma" })).toBe("gnome-libsecret"); + for (const session of [ + "kde", + "kde-plasma", + "kde4", + "plasma", + "plasmawayland", + "plasmawayland-dev", + "plasmax11", + "plasmax11-dev", + ]) { + expect(autoSwitch({ DESKTOP_SESSION: session })).toBe("gnome-libsecret"); + expect(autoSwitch({ XDG_SESSION_DESKTOP: session })).toBe("gnome-libsecret"); + expect(autoSwitch({ GDMSESSION: session })).toBe("gnome-libsecret"); + } + expect(autoSwitch({ DESKTOP_SESSION: "kde", KDE_SESSION_VERSION: "6" })).toBe( + "gnome-libsecret", + ); + expect(autoSwitch({ KDE_SESSION_VERSION: "6" })).toBe("gnome-libsecret"); + expect(autoSwitch({ KDE_FULL_SESSION: "true", KDE_SESSION_VERSION: "6" })).toBe( + "gnome-libsecret", + ); + }); + + it("ignores stale session hints when XDG_CURRENT_DESKTOP is authoritative", () => { + expect( + autoSwitch({ XDG_CURRENT_DESKTOP: "niri", DESKTOP_SESSION: "gnome", GDMSESSION: "gnome" }), + ).toBe("gnome-libsecret"); + // A previous KDE session left these behind; the compositor running now is not KDE. + expect(autoSwitch({ XDG_CURRENT_DESKTOP: "niri", KDE_SESSION_VERSION: "6" })).toBe( + "gnome-libsecret", + ); + expect(autoSwitch({ XDG_CURRENT_DESKTOP: "niri", XDG_SESSION_DESKTOP: "GNOME" })).toBe( + "gnome-libsecret", + ); + expect(autoSwitch({ XDG_CURRENT_DESKTOP: "niri:plasma" })).toBe("gnome-libsecret"); + expect( + autoSwitch({ + XDG_CURRENT_DESKTOP: "Hyprland", + XDG_SESSION_DESKTOP: "KDE", + KDE_SESSION_VERSION: "6", + }), + ).toBe("gnome-libsecret"); + }); + + it("uses GNOME Keyring remediation for libsecret and unknown backends", () => { + expect( + resolveLinuxSecretStorageUnavailableMessage({ + configuredPreference: "auto", + selectedBackend: "gnome_libsecret", + env: { XDG_CURRENT_DESKTOP: "niri" }, + }), + ).toContain("GNOME Keyring"); + }); + + it("prefers explicit libsecret selection over KDE desktop heuristics", () => { + expect( + resolveLinuxSecretStorageUnavailableMessage({ + configuredPreference: "gnome-libsecret", + selectedBackend: "unknown", + env: { XDG_CURRENT_DESKTOP: "KDE" }, + }), + ).toContain("GNOME Keyring"); + expect( + resolveLinuxSecretStorageUnavailableMessage({ + configuredPreference: "auto", + selectedBackend: "gnome_libsecret", + env: { XDG_CURRENT_DESKTOP: "KDE" }, + }), + ).toContain("GNOME Keyring"); + }); + + it("prefers explicit KWallet preference over selected gnome-libsecret backend", () => { + expect( + resolveLinuxSecretStorageUnavailableMessage({ + configuredPreference: "kwallet6", + selectedBackend: "gnome_libsecret", + env: { XDG_CURRENT_DESKTOP: "niri" }, + }), + ).toContain("KWallet"); + expect( + resolveLinuxSecretStorageUnavailableMessage({ + configuredPreference: "kwallet", + selectedBackend: "gnome-libsecret", + env: {}, + }), + ).toContain("KWallet"); + }); + + it("uses KWallet remediation wording for KDE-looking sessions", () => { + expect( + resolveLinuxSecretStorageUnavailableMessage({ + configuredPreference: "auto", + selectedBackend: "kwallet6", + env: {}, + }), + ).toContain("KWallet"); + expect( + resolveLinuxSecretStorageUnavailableMessage({ + configuredPreference: "auto", + selectedBackend: "unknown", + env: { XDG_CURRENT_DESKTOP: "KDE" }, + }), + ).toContain("KWallet"); + expect( + resolveLinuxSecretStorageUnavailableMessage({ + configuredPreference: "auto", + selectedBackend: "unknown", + env: { DESKTOP_SESSION: "plasmawayland" }, + }), + ).toContain("KWallet"); + // A desktop name outranks a bare KDE marker when choosing the wording. + expect( + resolveLinuxSecretStorageUnavailableMessage({ + configuredPreference: "auto", + selectedBackend: "unknown", + env: { GDMSESSION: "gnome", KDE_FULL_SESSION: "true" }, + }), + ).toContain("GNOME Keyring"); + }); +}); diff --git a/apps/desktop/src/linuxSecretStorage.ts b/apps/desktop/src/linuxSecretStorage.ts new file mode 100644 index 000000000..fe3e21ead --- /dev/null +++ b/apps/desktop/src/linuxSecretStorage.ts @@ -0,0 +1,178 @@ +export type LinuxPasswordStorePreference = + | "auto" + | "gnome-libsecret" + | "kwallet" + | "kwallet5" + | "kwallet6"; +export type LinuxPasswordStoreSwitch = Exclude; + +export const DEFAULT_LINUX_PASSWORD_STORE: LinuxPasswordStorePreference = "auto"; + +// Chromium matches XDG_CURRENT_DESKTOP values case-sensitively and returns on the first value it +// recognizes, so these stay exact literals and are scanned in order. Omitting a real desktop fails +// safe: we force gnome-libsecret, which is the backend Chromium selects for all of these anyway. +const ELECTRON_LIBSECRET_DESKTOPS = new Set([ + "Deepin", + "GNOME", + "Pantheon", + "UKUI", + "Unity", + "X-Cinnamon", + "XFCE", +]); +// Chromium selects a KWallet generation for KDE from KDE_SESSION_VERSION, so it needs no help. +const ELECTRON_KDE_DESKTOP = "KDE"; +// Chromium recognizes LXQt and still selects basic text for it, so it does need a forced backend. +const ELECTRON_UNPROTECTED_DESKTOPS = new Set(["LXQt"]); + +const KDE_NAME_PREFIXES = ["kde", "plasma"]; +const NEGATIVE_FLAG_VALUES = new Set(["0", "false", "no", "off"]); + +export function normalizeLinuxPasswordStorePreference( + value: unknown, +): LinuxPasswordStorePreference { + return value === "gnome-libsecret" || + value === "kwallet" || + value === "kwallet5" || + value === "kwallet6" + ? value + : DEFAULT_LINUX_PASSWORD_STORE; +} + +// Auto mode asks one question: will Electron select a real keyring on its own? If so, stay out of +// the way, which is how canonical KDE sessions keep the KWallet generation Chromium picks for them. +// Otherwise force gnome-libsecret, because the alternative is basic text, which is barely +// encryption at all. Forcing never guesses a KWallet generation; a KDE session that needs a +// specific one sets linuxPasswordStore explicitly. +export function resolveLinuxPasswordStoreSwitch(input: { + readonly preference: LinuxPasswordStorePreference; + readonly env: NodeJS.ProcessEnv; +}): LinuxPasswordStoreSwitch | null { + if (input.preference !== "auto") { + return input.preference; + } + + return electronSelectsProtectedBackend(input.env) ? null : "gnome-libsecret"; +} + +// Only an exact XDG_CURRENT_DESKTOP literal proves Electron will protect the session. Chromium can +// also reach a real backend through DESKTOP_SESSION and the legacy KDE markers, but those are the +// variables a previous session leaves behind, and trusting them is what let stale hints suppress +// the forced backend before. Forcing where Chromium would have chosen libsecret is harmless, since +// it lands on the same backend. +function electronSelectsProtectedBackend(env: NodeJS.ProcessEnv): boolean { + for (const name of splitDesktopNameList(env.XDG_CURRENT_DESKTOP)) { + const trimmed = name.trim(); + if (trimmed.length === 0) { + continue; + } + if (trimmed === ELECTRON_KDE_DESKTOP || ELECTRON_LIBSECRET_DESKTOPS.has(trimmed)) { + return true; + } + if (ELECTRON_UNPROTECTED_DESKTOPS.has(trimmed)) { + return false; + } + } + + return false; +} + +export function resolveLinuxSecretStorageUnavailableMessage(input: { + readonly configuredPreference: LinuxPasswordStorePreference; + readonly selectedBackend: string | null; + readonly env: NodeJS.ProcessEnv; +}): string { + if (input.configuredPreference === "gnome-libsecret") { + return getGnomeKeyringRemediationMessage(); + } + + if ( + input.configuredPreference === "kwallet" || + input.configuredPreference === "kwallet5" || + input.configuredPreference === "kwallet6" + ) { + return getKWalletRemediationMessage(); + } + + const backend = normalizeSelectedStorageBackend(input.selectedBackend); + if (backend === "gnome-libsecret") { + return getGnomeKeyringRemediationMessage(); + } + + if ( + backend === "kwallet" || + backend === "kwallet5" || + backend === "kwallet6" || + looksLikeKdeSession(input.env) + ) { + return getKWalletRemediationMessage(); + } + + return getGnomeKeyringRemediationMessage(); +} + +function getGnomeKeyringRemediationMessage(): string { + return "T3 Code could not access GNOME Keyring to save this environment credential. Install and start GNOME Keyring, then restart T3 Code."; +} + +function getKWalletRemediationMessage(): string { + return "T3 Code could not access KWallet to save this environment credential. Enable the KDE wallet subsystem in System Settings, then restart T3 Code."; +} + +// Advisory only: this picks between the GNOME Keyring and KWallet wording in the failure notice. It +// never decides which backend to select, so a loose match costs a user slightly wrong instructions +// rather than an unprotected credential store. +function looksLikeKdeSession(env: NodeJS.ProcessEnv): boolean { + const currentDesktopNames = nonEmptyDesktopNames(env.XDG_CURRENT_DESKTOP); + if (currentDesktopNames.length > 0) { + return currentDesktopNames.some(isKdeDesktopName); + } + + const legacyNames = legacyDesktopNames(env); + if (legacyNames.length > 0) { + return legacyNames.some(isKdeDesktopName); + } + + return isSet(env.KDE_SESSION_VERSION) || isAffirmativeFlag(env.KDE_FULL_SESSION); +} + +function isKdeDesktopName(name: string): boolean { + return KDE_NAME_PREFIXES.some((prefix) => name.startsWith(prefix)); +} + +function legacyDesktopNames(env: NodeJS.ProcessEnv): string[] { + return [env.XDG_SESSION_DESKTOP, env.DESKTOP_SESSION, env.GDMSESSION].flatMap((entry) => { + const normalized = normalizeDesktopName(entry); + return normalized ? [normalized] : []; + }); +} + +function nonEmptyDesktopNames(value: string | undefined): string[] { + return splitDesktopNameList(value).flatMap((entry) => { + const normalized = normalizeDesktopName(entry); + return normalized ? [normalized] : []; + }); +} + +function isSet(value: string | undefined): boolean { + return Boolean(value?.trim()); +} + +function isAffirmativeFlag(value: string | undefined): boolean { + const normalized = value?.trim().toLowerCase(); + return normalized ? !NEGATIVE_FLAG_VALUES.has(normalized) : false; +} + +function splitDesktopNameList(value: string | undefined): string[] { + return value?.split(":") ?? []; +} + +function normalizeDesktopName(value: string | undefined): string | null { + const normalized = value?.trim().toLowerCase(); + return normalized && normalized.length > 0 ? normalized : null; +} + +function normalizeSelectedStorageBackend(value: string | null): string | null { + const normalized = value?.trim().toLowerCase().replace(/_/gu, "-"); + return normalized && normalized.length > 0 ? normalized : null; +} diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index ad370e36f..0616184ec 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -43,12 +43,14 @@ import * as DesktopLocalEnvironmentAuth from "./backend/DesktopLocalEnvironmentA import * as DesktopNetworkInterfaces from "./backend/DesktopNetworkInterfaces.ts"; import * as DesktopEnvironment from "./app/DesktopEnvironment.ts"; import * as DesktopLifecycle from "./app/DesktopLifecycle.ts"; +import * as DesktopLinuxUrlHandler from "./app/DesktopLinuxUrlHandler.ts"; import * as DesktopShutdown from "./app/DesktopShutdown.ts"; import * as DesktopObservability from "./app/DesktopObservability.ts"; import * as DesktopServerExposure from "./backend/DesktopServerExposure.ts"; import * as DesktopClientSettings from "./settings/DesktopClientSettings.ts"; import * as DesktopSavedEnvironments from "./settings/DesktopSavedEnvironments.ts"; import * as DesktopAppSettings from "./settings/DesktopAppSettings.ts"; +import * as DesktopPreReadyPlatform from "./app/DesktopPreReadyPlatform.ts"; import * as DesktopShellEnvironment from "./shell/DesktopShellEnvironment.ts"; import * as DesktopSshEnvironment from "./ssh/DesktopSshEnvironment.ts"; import * as DesktopSshPasswordPrompts from "./ssh/DesktopSshPasswordPrompts.ts"; @@ -181,6 +183,7 @@ const desktopLocalEnvironmentAuthLayer = DesktopLocalEnvironmentAuth.layer.pipe( const desktopApplicationLayer = Layer.mergeAll( DesktopLifecycle.layer, DesktopApplicationMenu.layer, + DesktopLinuxUrlHandler.layer, DesktopShellEnvironment.layer, desktopSshLayer, ).pipe( @@ -195,16 +198,20 @@ const desktopClerkLayer = DesktopClerk.layer.pipe( Layer.provideMerge(ElectronApp.layer), ); +const desktopApplicationRuntimeLayer = desktopApplicationLayer.pipe( + Layer.provideMerge(NodeServices.layer), + Layer.provideMerge(NodeHttpClient.layerUndici), + Layer.provideMerge(NetService.layer), + Layer.provideMerge(electronLayer), +); + +// Acquire strict pre-ready setup before Clerk, whose userData resolution can +// yield and let Electron emit ready. const desktopRuntimeLayer = desktopClerkLayer.pipe( Layer.flatMap((clerkContext) => - desktopApplicationLayer.pipe( - Layer.provideMerge(Layer.succeedContext(clerkContext)), - Layer.provideMerge(NodeServices.layer), - Layer.provideMerge(NodeHttpClient.layerUndici), - Layer.provideMerge(NetService.layer), - Layer.provideMerge(electronLayer), - ), + desktopApplicationRuntimeLayer.pipe(Layer.provideMerge(Layer.succeedContext(clerkContext))), ), + Layer.provideMerge(DesktopPreReadyPlatform.layer), ); DesktopApp.program.pipe(Effect.provide(desktopRuntimeLayer), NodeRuntime.runMain); diff --git a/apps/desktop/src/settings/DesktopAppSettings.test.ts b/apps/desktop/src/settings/DesktopAppSettings.test.ts index 3878b0e36..64c59749a 100644 --- a/apps/desktop/src/settings/DesktopAppSettings.test.ts +++ b/apps/desktop/src/settings/DesktopAppSettings.test.ts @@ -11,6 +11,9 @@ import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; import * as DesktopAppSettings from "./DesktopAppSettings.ts"; const DesktopSettingsPatch = Schema.Struct({ + linuxPasswordStore: Schema.optionalKey( + Schema.Literals(["auto", "gnome-libsecret", "kwallet", "kwallet5", "kwallet6"]), + ), mainWindowBounds: Schema.optionalKey( Schema.NullOr( Schema.Struct({ @@ -102,6 +105,7 @@ describe("DesktopSettings", () => { assert.deepEqual( DesktopAppSettings.resolveDefaultDesktopSettings("0.0.17-nightly.20260415.1"), { + linuxPasswordStore: "auto", mainWindowBounds: null, mainWindowMaximized: false, serverExposureMode: "local-only", @@ -121,6 +125,7 @@ describe("DesktopSettings", () => { Effect.gen(function* () { const settings = yield* DesktopAppSettings.DesktopAppSettings; yield* writeSettingsPatch({ + linuxPasswordStore: "gnome-libsecret", serverExposureMode: "network-accessible", tailscaleServeEnabled: true, tailscaleServePort: 8443, @@ -129,6 +134,7 @@ describe("DesktopSettings", () => { }); assert.deepEqual(yield* settings.load, { + linuxPasswordStore: "gnome-libsecret", mainWindowBounds: null, mainWindowMaximized: false, serverExposureMode: "network-accessible", @@ -235,6 +241,7 @@ describe("DesktopSettings", () => { ); assert.deepEqual(yield* settings.load, { + linuxPasswordStore: "auto", mainWindowBounds: { x: 120, y: 80, width: 1280, height: 900 }, mainWindowMaximized: false, serverExposureMode: "network-accessible", @@ -268,6 +275,44 @@ describe("DesktopSettings", () => { ), ); + it.effect( + "normalizes unsupported linux password-store values without dropping other settings", + () => + withSettings( + Effect.gen(function* () { + const environment = yield* DesktopEnvironment.DesktopEnvironment; + const fileSystem = yield* FileSystem.FileSystem; + const settings = yield* DesktopAppSettings.DesktopAppSettings; + yield* fileSystem.makeDirectory(environment.stateDir, { recursive: true }); + yield* fileSystem.writeFileString( + environment.desktopSettingsPath, + `{ + "linuxPasswordStore": "unsupported-store", + "serverExposureMode": "network-accessible", + "tailscaleServeEnabled": true, + "tailscaleServePort": 8443, + "updateChannel": "nightly", + "updateChannelConfiguredByUser": true + }\n`, + ); + + assert.deepEqual(yield* settings.load, { + linuxPasswordStore: "auto", + mainWindowBounds: null, + mainWindowMaximized: false, + serverExposureMode: "network-accessible", + tailscaleServeEnabled: true, + tailscaleServePort: 8443, + updateChannel: "nightly", + updateChannelConfiguredByUser: true, + wslBackendEnabled: false, + wslOnly: false, + wslDistro: null, + } satisfies DesktopAppSettings.DesktopSettings); + }), + ), + ); + it.effect("persists sparse desktop settings documents", () => withSettings( Effect.gen(function* () { @@ -300,6 +345,7 @@ describe("DesktopSettings", () => { }); assert.deepEqual(yield* settings.load, { + linuxPasswordStore: "auto", mainWindowBounds: null, mainWindowMaximized: false, serverExposureMode: "local-only", @@ -327,6 +373,7 @@ describe("DesktopSettings", () => { }); assert.deepEqual(yield* settings.load, { + linuxPasswordStore: "auto", mainWindowBounds: null, mainWindowMaximized: false, serverExposureMode: "local-only", @@ -353,6 +400,7 @@ describe("DesktopSettings", () => { }); assert.deepEqual(yield* settings.load, { + linuxPasswordStore: "auto", mainWindowBounds: null, mainWindowMaximized: false, serverExposureMode: "local-only", diff --git a/apps/desktop/src/settings/DesktopAppSettings.ts b/apps/desktop/src/settings/DesktopAppSettings.ts index 466c9a9b5..aefc67525 100644 --- a/apps/desktop/src/settings/DesktopAppSettings.ts +++ b/apps/desktop/src/settings/DesktopAppSettings.ts @@ -16,10 +16,16 @@ import * as Schema from "effect/Schema"; import * as SynchronizedRef from "effect/SynchronizedRef"; import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; +import { + DEFAULT_LINUX_PASSWORD_STORE, + normalizeLinuxPasswordStorePreference, + type LinuxPasswordStorePreference, +} from "../linuxSecretStorage.ts"; import { resolveDefaultDesktopUpdateChannel } from "../updates/updateChannels.ts"; import { isValidDistroName } from "../wsl/wslPathParsing.ts"; export interface DesktopSettings { + readonly linuxPasswordStore: LinuxPasswordStorePreference; readonly mainWindowBounds: DesktopWindowBounds | null; readonly mainWindowMaximized: boolean; readonly serverExposureMode: DesktopServerExposureMode; @@ -67,6 +73,7 @@ export const DEFAULT_MAIN_WINDOW_SIZE = { } as const; export const DEFAULT_DESKTOP_SETTINGS: DesktopSettings = { + linuxPasswordStore: DEFAULT_LINUX_PASSWORD_STORE, mainWindowBounds: null, mainWindowMaximized: false, serverExposureMode: "local-only", @@ -87,6 +94,7 @@ const DesktopWindowBoundsDocument = Schema.Struct({ }); const DesktopSettingsDocument = Schema.Struct({ + linuxPasswordStore: Schema.optionalKey(Schema.Unknown), mainWindowBounds: Schema.optionalKey(Schema.NullOr(DesktopWindowBoundsDocument)), mainWindowMaximized: Schema.optionalKey(Schema.Boolean), serverExposureMode: Schema.optionalKey(DesktopServerExposureModeSchema), @@ -216,6 +224,7 @@ function normalizeDesktopSettingsDocument( (parsed.wslBackendEnabled === undefined && parsed.wslMode === "wsl"); return { + linuxPasswordStore: normalizeLinuxPasswordStorePreference(parsed.linuxPasswordStore), mainWindowBounds, mainWindowMaximized: mainWindowBounds !== null && parsed.mainWindowMaximized === true, serverExposureMode: @@ -238,6 +247,9 @@ function toDesktopSettingsDocument( ): DesktopSettingsDocument { const document: Mutable = {}; + if (settings.linuxPasswordStore !== defaults.linuxPasswordStore) { + document.linuxPasswordStore = settings.linuxPasswordStore; + } if (settings.mainWindowBounds !== null) { document.mainWindowBounds = settings.mainWindowBounds; } diff --git a/apps/desktop/src/settings/DesktopSavedEnvironments.test.ts b/apps/desktop/src/settings/DesktopSavedEnvironments.test.ts index ec70308b3..05b1ca144 100644 --- a/apps/desktop/src/settings/DesktopSavedEnvironments.test.ts +++ b/apps/desktop/src/settings/DesktopSavedEnvironments.test.ts @@ -86,6 +86,7 @@ function makeSafeStorageLayer(input: { } return Effect.succeed(decoded.slice("enc:".length)); }, + selectedStorageBackend: Effect.succeed(Option.none()), } satisfies ElectronSafeStorage.ElectronSafeStorage["Service"]); } diff --git a/apps/desktop/src/shell/DesktopShellEnvironment.test.ts b/apps/desktop/src/shell/DesktopShellEnvironment.test.ts index 7ec0ab80a..b8c66e9b7 100644 --- a/apps/desktop/src/shell/DesktopShellEnvironment.test.ts +++ b/apps/desktop/src/shell/DesktopShellEnvironment.test.ts @@ -1,3 +1,4 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, describe, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; @@ -90,7 +91,7 @@ function runShellEnvironment(input: { }).pipe( Effect.provide( DesktopShellEnvironment.layer.pipe( - Layer.provide(Layer.mergeAll(environmentLayer, spawnerLayer)), + Layer.provide(Layer.mergeAll(environmentLayer, NodeServices.layer, spawnerLayer)), ), ), ); @@ -243,6 +244,65 @@ describe("DesktopShellEnvironment", () => { }), ); + it.effect("prefers login-shell desktop session hints over inherited values on linux", () => + Effect.gen(function* () { + const env: NodeJS.ProcessEnv = { + SHELL: "/bin/zsh", + PATH: "/usr/bin", + XDG_CURRENT_DESKTOP: "wrong-launcher", + XDG_SESSION_DESKTOP: "wrong-launcher", + }; + + yield* runShellEnvironment({ + env, + platform: "linux", + handler: () => + envOutput({ + PATH: "/home/linuxbrew/.linuxbrew/bin:/usr/bin", + XDG_CURRENT_DESKTOP: "KDE", + XDG_SESSION_DESKTOP: "KDE", + XDG_SESSION_TYPE: "wayland", + }), + }); + + assert.equal(env.XDG_CURRENT_DESKTOP, "KDE"); + assert.equal(env.XDG_SESSION_DESKTOP, "KDE"); + assert.equal(env.XDG_SESSION_TYPE, "wayland"); + }), + ); + + it.effect("overrides stale dbus session addresses from the login shell", () => + Effect.gen(function* () { + const env: NodeJS.ProcessEnv = { + SHELL: "/bin/zsh", + PATH: "/usr/bin", + DBUS_SESSION_BUS_ADDRESS: "unix:path=/tmp/stale-bus", + }; + + yield* runShellEnvironment({ + env, + platform: "linux", + handler: () => + envOutput({ + PATH: "/usr/bin", + DBUS_SESSION_BUS_ADDRESS: "unix:path=/run/user/1000/bus", + }), + }); + + assert.equal(env.DBUS_SESSION_BUS_ADDRESS, "unix:path=/run/user/1000/bus"); + }), + ); + + it("resolves dbus runtime dir candidates with existence checks", () => { + const busPath = DesktopShellEnvironment.resolveDefaultLinuxDbusSessionBusAddress({ + env: { XDG_RUNTIME_DIR: "/tmp/stale-runtime" }, + uid: 1000, + exists: (path) => path === "/run/user/1000/bus", + }); + + assert.equal(busPath, "unix:path=/run/user/1000/bus"); + }); + it.effect("logs command failures with safe probe context and the exact cause", () => { const env: NodeJS.ProcessEnv = { SHELL: "/bin/bash", diff --git a/apps/desktop/src/shell/DesktopShellEnvironment.ts b/apps/desktop/src/shell/DesktopShellEnvironment.ts index 8219f18b7..5627eec54 100644 --- a/apps/desktop/src/shell/DesktopShellEnvironment.ts +++ b/apps/desktop/src/shell/DesktopShellEnvironment.ts @@ -1,6 +1,7 @@ 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 Option from "effect/Option"; import * as Schema from "effect/Schema"; @@ -68,12 +69,19 @@ export class DesktopShellEnvironment extends Context.Service< const LOGIN_SHELL_ENV_NAMES = [ "PATH", + "DBUS_SESSION_BUS_ADDRESS", + "DISPLAY", "SSH_AUTH_SOCK", "HOMEBREW_PREFIX", "HOMEBREW_CELLAR", "HOMEBREW_REPOSITORY", "XDG_CONFIG_HOME", + "XDG_CURRENT_DESKTOP", "XDG_DATA_HOME", + "XDG_RUNTIME_DIR", + "XDG_SESSION_DESKTOP", + "XDG_SESSION_TYPE", + "WAYLAND_DISPLAY", ] as const; const WINDOWS_PROFILE_ENV_NAMES = ["PATH", "FNM_DIR", "FNM_MULTISHELL_PATH"] as const; const WINDOWS_SHELL_CANDIDATES = ["pwsh.exe", "powershell.exe"] as const; @@ -92,6 +100,47 @@ const pathDelimiter = (platform: NodeJS.Platform) => (platform === "win32" ? ";" const readEnvPath = (env: NodeJS.ProcessEnv): Option.Option => trimNonEmpty(env.PATH ?? env.Path ?? env.path); +const normalizeRuntimeDir = (value: string): string => value.replace(/\/+$/u, ""); + +const linuxRuntimeDirCandidates = ( + env: NodeJS.ProcessEnv, + uid: number | undefined, +): ReadonlyArray => { + const candidates: string[] = []; + const fromEnv = trimNonEmpty(env.XDG_RUNTIME_DIR); + if (Option.isSome(fromEnv)) { + candidates.push(normalizeRuntimeDir(fromEnv.value)); + } + if (uid !== undefined) { + candidates.push(`/run/user/${uid}`); + } + return candidates.filter((candidate) => candidate.length > 0); +}; + +function resolveDefaultLinuxDbusSessionBusPath(input: { + readonly env: NodeJS.ProcessEnv; + readonly uid: number | undefined; + readonly exists?: (path: string) => boolean; +}): string | null { + for (const runtimeDir of linuxRuntimeDirCandidates(input.env, input.uid)) { + const busPath = `${runtimeDir}/bus`; + if (input.exists === undefined || input.exists(busPath)) { + return busPath; + } + } + + return null; +} + +export function resolveDefaultLinuxDbusSessionBusAddress(input: { + readonly env: NodeJS.ProcessEnv; + readonly exists: (path: string) => boolean; + readonly uid: number | undefined; +}): string | null { + const busPath = resolveDefaultLinuxDbusSessionBusPath(input); + return busPath !== null && input.exists(busPath) ? `unix:path=${busPath}` : null; +} + const pathComparisonKey = (entry: string, platform: NodeJS.Platform) => { const normalized = entry.trim().replace(/^"+|"+$/g, ""); return platform === "win32" ? normalized.toLowerCase() : normalized; @@ -356,7 +405,12 @@ const installWindowsEnvironment = Effect.fn("desktop.shellEnvironment.installWin const installPosixEnvironment = Effect.fn("desktop.shellEnvironment.installPosixEnvironment")( function* ( config: ShellEnvironmentConfig, - ): Effect.fn.Return { + ): Effect.fn.Return< + void, + never, + ChildProcessSpawner.ChildProcessSpawner | FileSystem.FileSystem + > { + const fileSystem = yield* FileSystem.FileSystem; const shellEnvironment: EnvironmentPatch = {}; for (const shell of listLoginShellCandidates(config)) { @@ -383,23 +437,54 @@ const installPosixEnvironment = Effect.fn("desktop.shellEnvironment.installPosix config.env.SSH_AUTH_SOCK = shellEnvironment.SSH_AUTH_SOCK; } + const shellPreferredEnvNames = [ + "DBUS_SESSION_BUS_ADDRESS", + "XDG_CURRENT_DESKTOP", + "XDG_SESSION_DESKTOP", + "XDG_SESSION_TYPE", + ] as const; + for (const name of shellPreferredEnvNames) { + if (shellEnvironment[name]) { + config.env[name] = shellEnvironment[name]; + } + } + for (const name of [ + "DISPLAY", "HOMEBREW_PREFIX", "HOMEBREW_CELLAR", "HOMEBREW_REPOSITORY", "XDG_CONFIG_HOME", "XDG_DATA_HOME", + "XDG_RUNTIME_DIR", + "WAYLAND_DISPLAY", ] as const) { if (!config.env[name] && shellEnvironment[name]) { config.env[name] = shellEnvironment[name]; } } + + if ( + config.platform === "linux" && + Option.isNone(trimNonEmpty(config.env.DBUS_SESSION_BUS_ADDRESS)) + ) { + for (const runtimeDir of linuxRuntimeDirCandidates(config.env, process.getuid?.())) { + const dbusSessionBusPath = `${runtimeDir}/bus`; + const busExists = yield* fileSystem + .exists(dbusSessionBusPath) + .pipe(Effect.orElseSucceed(() => false)); + if (busExists) { + config.env.DBUS_SESSION_BUS_ADDRESS = `unix:path=${dbusSessionBusPath}`; + break; + } + } + } }, ); const installShellEnvironment = ( config: ShellEnvironmentConfig, -): Effect.Effect => { +): Effect.Effect => { if (config.platform === "win32") { return installWindowsEnvironment(config); } @@ -411,6 +496,7 @@ const installShellEnvironment = ( export const make = Effect.gen(function* () { const environment = yield* DesktopEnvironment.DesktopEnvironment; + const fileSystem = yield* FileSystem.FileSystem; const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const installIntoProcess: DesktopShellEnvironment["Service"]["installIntoProcess"] = installShellEnvironment({ @@ -418,6 +504,7 @@ export const make = Effect.gen(function* () { platform: environment.platform, userShell: Option.none(), }).pipe( + Effect.provideService(FileSystem.FileSystem, fileSystem), Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), Effect.withSpan("desktop.shellEnvironment.installIntoProcess"), ); diff --git a/apps/desktop/src/telemetry/DesktopTelemetryPublisher.test.ts b/apps/desktop/src/telemetry/DesktopTelemetryPublisher.test.ts index 36cdcb50b..112c0ab35 100644 --- a/apps/desktop/src/telemetry/DesktopTelemetryPublisher.test.ts +++ b/apps/desktop/src/telemetry/DesktopTelemetryPublisher.test.ts @@ -43,6 +43,7 @@ function makeElectronAppLayer( setDesktopName: () => Effect.void, setDockIcon: () => Effect.void, appendCommandLineSwitch: () => Effect.void, + removeCommandLineSwitch: () => Effect.void, onBeforeQuitForUpdate: () => Effect.void, on: () => Effect.void, } satisfies ElectronApp.ElectronApp["Service"]); diff --git a/apps/desktop/src/window/DesktopApplicationMenu.test.ts b/apps/desktop/src/window/DesktopApplicationMenu.test.ts index f04a49f82..136cf8204 100644 --- a/apps/desktop/src/window/DesktopApplicationMenu.test.ts +++ b/apps/desktop/src/window/DesktopApplicationMenu.test.ts @@ -46,6 +46,7 @@ const electronAppLayer = Layer.succeed(ElectronApp.ElectronApp, { setDockIcon: () => Effect.void, appendCommandLineSwitch: () => Effect.void, onBeforeQuitForUpdate: () => Effect.void, + removeCommandLineSwitch: () => Effect.void, on: () => Effect.void, } satisfies ElectronApp.ElectronApp["Service"]); diff --git a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx index e920fddb6..6d204e3a8 100644 --- a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx +++ b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx @@ -43,7 +43,7 @@ import { type ComposerDraft, } from "../../state/use-composer-drafts"; import { useEnvironmentServerConfig, useProjects } from "../../state/entities"; -import { resolveSelectableModelSelection } from "../../lib/modelOptions"; +import { buildModelMenuActions, resolveSelectableModelSelection } from "../../lib/modelOptions"; import { deriveThreadTitleFromPrompt } from "../../lib/projectThreadStartTurn"; import { armAgentAwarenessLiveActivityForLocalWork } from "../agent-awareness/remoteRegistration"; import { enqueueThreadOutboxMessage, removeThreadOutboxMessage } from "../../state/thread-outbox"; @@ -543,27 +543,7 @@ export function NewTaskDraftScreen(props: { ); const modelMenuActions = useMemo( - () => - flow.providerGroups.map((group) => ({ - id: `provider:${group.providerKey}`, - title: group.providerLabel, - subtitle: group.models.find( - (model) => - flow.selectedModel && - model.selection.instanceId === flow.selectedModel.instanceId && - model.selection.model === flow.selectedModel.model, - )?.label, - subactions: group.models.map((option) => ({ - id: `model:${option.key}`, - title: option.label, - state: - flow.selectedModel && - option.selection.instanceId === flow.selectedModel.instanceId && - option.selection.model === flow.selectedModel.model - ? ("on" as const) - : undefined, - })), - })), + () => buildModelMenuActions(flow.providerGroups, flow.selectedModel), [flow.providerGroups, flow.selectedModel], ); const providerOptionDescriptors = useMemo( diff --git a/apps/mobile/src/features/threads/ThreadComposer.tsx b/apps/mobile/src/features/threads/ThreadComposer.tsx index fc45cba42..aab896efe 100644 --- a/apps/mobile/src/features/threads/ThreadComposer.tsx +++ b/apps/mobile/src/features/threads/ThreadComposer.tsx @@ -53,7 +53,7 @@ import { import { ControlPill, ControlPillMenu } from "../../components/ControlPill"; import { ProviderIcon } from "../../components/ProviderIcon"; import type { DraftComposerImageAttachment } from "../../lib/composerImages"; -import { buildModelOptions, groupByProvider } from "../../lib/modelOptions"; +import { buildModelMenuActions, buildModelOptions, groupByProvider } from "../../lib/modelOptions"; import { useScaledTextRole } from "../settings/appearance/useScaledTextRole"; import type { RemoteClientConnectionState } from "../../lib/connection"; import { @@ -606,25 +606,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer [providerOptionDescriptors], ); const modelMenuActions = useMemo( - () => - providerGroups.map((group) => ({ - id: `provider:${group.providerKey}`, - title: group.providerLabel, - subtitle: group.models.find( - (model) => - model.selection.instanceId === currentModelSelection.instanceId && - model.selection.model === currentModelSelection.model, - )?.label, - subactions: group.models.map((option) => ({ - id: `model:${option.key}`, - title: option.label, - state: - option.selection.instanceId === currentModelSelection.instanceId && - option.selection.model === currentModelSelection.model - ? ("on" as const) - : undefined, - })), - })), + () => buildModelMenuActions(providerGroups, currentModelSelection), [providerGroups, currentModelSelection], ); diff --git a/apps/mobile/src/lib/modelOptions.test.ts b/apps/mobile/src/lib/modelOptions.test.ts index f9e1e2578..2ec8566b4 100644 --- a/apps/mobile/src/lib/modelOptions.test.ts +++ b/apps/mobile/src/lib/modelOptions.test.ts @@ -2,9 +2,92 @@ import { describe, expect, it } from "vite-plus/test"; import { ProviderInstanceId, type ServerConfig } from "@t3tools/contracts"; -import { buildModelOptions, resolveSelectableModelSelection } from "./modelOptions"; +import { + buildModelMenuActions, + buildModelOptions, + groupByProvider, + resolveSelectableModelSelection, +} from "./modelOptions"; describe("mobile model options", () => { + it("folds legacy models into a provider-scoped menu", () => { + const config = { + providers: [ + { + instanceId: "codex", + driver: "codex", + displayName: "Codex", + enabled: true, + installed: true, + auth: { status: "authenticated" }, + models: [ + { + slug: "gpt-5.6-sol", + name: "GPT-5.6 Sol", + isCustom: false, + capabilities: null, + }, + { + slug: "gpt-5.4", + name: "GPT-5.4", + isCustom: false, + isLegacy: true, + capabilities: null, + }, + ], + }, + ], + } as unknown as ServerConfig; + + const actions = buildModelMenuActions(groupByProvider(buildModelOptions(config, null)), null); + + expect(actions).toMatchObject([ + { + title: "Codex", + subactions: [{ id: "model:codex:gpt-5.6-sol", title: "GPT-5.6 Sol" }], + }, + { + id: "legacy-models:codex", + title: "Codex legacy models", + subactions: [{ id: "model:codex:gpt-5.4", title: "GPT-5.4" }], + }, + ]); + }); + + it("omits an empty provider menu when every model is legacy", () => { + const config = { + providers: [ + { + instanceId: "codex", + driver: "codex", + displayName: "Codex", + enabled: true, + installed: true, + auth: { status: "authenticated" }, + models: [ + { + slug: "gpt-5.4", + name: "GPT-5.4", + isCustom: false, + isLegacy: true, + capabilities: null, + }, + ], + }, + ], + } as unknown as ServerConfig; + + expect( + buildModelMenuActions(groupByProvider(buildModelOptions(config, null)), null), + ).toMatchObject([ + { + id: "legacy-models:codex", + title: "Codex legacy models", + subactions: [{ id: "model:codex:gpt-5.4" }], + }, + ]); + }); + it("normalizes a legacy fallback selection against current capabilities", () => { const config = { providers: [ diff --git a/apps/mobile/src/lib/modelOptions.ts b/apps/mobile/src/lib/modelOptions.ts index b51fa915d..951b74f7d 100644 --- a/apps/mobile/src/lib/modelOptions.ts +++ b/apps/mobile/src/lib/modelOptions.ts @@ -3,6 +3,7 @@ import type { ModelSelection, ServerConfig as T3ServerConfig, } from "@t3tools/contracts"; +import type { MenuAction } from "@react-native-menu/menu"; import { buildProviderOptionSelectionsFromDescriptors, getProviderOptionDescriptors, @@ -16,6 +17,7 @@ export type ModelOption = { readonly providerLabel: string; readonly providerDriver: string; readonly isDefault: boolean; + readonly isLegacy: boolean; readonly capabilities: ModelCapabilities | null; readonly selection: ModelSelection; }; @@ -105,6 +107,7 @@ export function buildModelOptions( providerLabel, providerDriver: provider.driver, isDefault: model.isDefault === true, + isLegacy: model.isLegacy === true, capabilities: model.capabilities, selection: normalizeSelectionOptions( { @@ -135,6 +138,7 @@ export function buildModelOptions( providerLabel, providerDriver: fallbackModelSelection.instanceId, isDefault: false, + isLegacy: false, capabilities: null, selection: fallbackModelSelection, }); @@ -164,3 +168,53 @@ export function groupByProvider(options: ReadonlyArray): ReadonlyAr models: group.models, })); } + +function modelMenuAction(option: ModelOption, selectedModel: ModelSelection | null): MenuAction { + return { + id: `model:${option.key}`, + title: option.label, + state: + option.selection.instanceId === selectedModel?.instanceId && + option.selection.model === selectedModel.model + ? "on" + : undefined, + }; +} + +export function buildModelMenuActions( + groups: ReadonlyArray, + selectedModel: ModelSelection | null, +): MenuAction[] { + return groups.flatMap((group) => { + const currentModels = group.models.filter((model) => !model.isLegacy); + const legacyModels = group.models.filter((model) => model.isLegacy); + const selected = group.models.find( + (model) => + model.selection.instanceId === selectedModel?.instanceId && + model.selection.model === selectedModel.model, + ); + + return [ + ...(currentModels.length > 0 + ? [ + { + id: `provider:${group.providerKey}`, + title: group.providerLabel, + subtitle: selected && !selected.isLegacy ? selected.label : undefined, + subactions: currentModels.map((option) => modelMenuAction(option, selectedModel)), + }, + ] + : []), + ...(legacyModels.length > 0 + ? [ + { + id: `legacy-models:${group.providerKey}`, + title: `${group.providerLabel} legacy models`, + subtitle: selected?.isLegacy ? selected.label : undefined, + subactions: legacyModels.map((option) => modelMenuAction(option, selectedModel)), + }, + ] + : []), + ]; + }); +} diff --git a/apps/server/package.json b/apps/server/package.json index e87807a91..0ab8b4952 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -17,7 +17,7 @@ "type": "module", "scripts": { "dev": "node --watch src/bin.ts", - "build:bundle": "vp pack", + "build:bundle": "vp pack && vp pack src/service-launcher.ts --out-dir dist --no-clean", "start": "node dist/bin.mjs", "typecheck": "tsgo --noEmit", "test": "vp test run" diff --git a/apps/server/scripts/cli.ts b/apps/server/scripts/cli.ts index 517f63357..2de5b702a 100644 --- a/apps/server/scripts/cli.ts +++ b/apps/server/scripts/cli.ts @@ -223,7 +223,11 @@ const publishCmd = Command.make( const packageJsonPath = path.join(serverDir, "package.json"); // Assert build assets exist - for (const relPath of ["dist/bin.mjs", "dist/client/index.html"]) { + for (const relPath of [ + "dist/bin.mjs", + "dist/service-launcher.mjs", + "dist/client/index.html", + ]) { const abs = path.join(serverDir, relPath); if (!(yield* fs.exists(abs))) { return yield* new ServerCliBuildAssetMissingError({ assetPath: abs }); diff --git a/apps/server/src/bin.ts b/apps/server/src/bin.ts index ab60749e3..d1bdcf909 100644 --- a/apps/server/src/bin.ts +++ b/apps/server/src/bin.ts @@ -15,6 +15,7 @@ import { sharedServerCommandFlags } from "./cli/config.ts"; import { projectCommand } from "./cli/project.ts"; import { runServerCommand, serveCommand, startCommand } from "./cli/server.ts"; import { serviceCommand } from "./cli/service.ts"; +import { servicePreflightCommand } from "./cli/servicePreflight.ts"; const CliRuntimeLayer = Layer.mergeAll(NodeServices.layer, NetService.layer); @@ -53,6 +54,7 @@ export const makeCli = ({ cloudEnabled = hasCloudPublicConfig } = {}) => authCommand, projectCommand, serviceCommand, + servicePreflightCommand, cloudEnabled ? connectCommand : connectUnavailableCommand, ]), ); diff --git a/apps/server/src/cli/servicePreflight.ts b/apps/server/src/cli/servicePreflight.ts new file mode 100644 index 000000000..60a457f38 --- /dev/null +++ b/apps/server/src/cli/servicePreflight.ts @@ -0,0 +1,17 @@ +import * as Console from "effect/Console"; +import * as Effect from "effect/Effect"; +import { Command, Flag } from "effect/unstable/cli"; + +import { runServicePreflight } from "../cloud/servicePreflight.ts"; + +export const servicePreflightCommand = Command.make("__service-preflight", { + databasePath: Flag.string("database-path"), + launcherProtocol: Flag.integer("launcher-protocol"), +}).pipe( + Command.withHidden, + Command.withHandler(({ databasePath, launcherProtocol }) => + Console.log(JSON.stringify(runServicePreflight({ databasePath, launcherProtocol }))).pipe( + Effect.asVoid, + ), + ), +); diff --git a/apps/server/src/cloud/ManagedEndpointRuntime.test.ts b/apps/server/src/cloud/ManagedEndpointRuntime.test.ts index e0d5924fc..b45b50992 100644 --- a/apps/server/src/cloud/ManagedEndpointRuntime.test.ts +++ b/apps/server/src/cloud/ManagedEndpointRuntime.test.ts @@ -96,6 +96,15 @@ describe("CloudManagedEndpointRuntime", () => { "2026-06-17T02:00:00Z INF Starting metrics server", ), ).toBe("debug"); + // FTL (fatal) and PNC (panic) are more severe than ERR and must surface. + expect( + ManagedEndpointRuntime.classifyRelayClientOutput( + "2026-06-17T02:00:00Z FTL Cannot determine default origin certificate path", + ), + ).toBe("warning"); + expect( + ManagedEndpointRuntime.classifyRelayClientOutput("2026-06-17T02:00:00Z PNC runtime panic"), + ).toBe("warning"); }); it.effect("starts, deduplicates, rotates, and stops the Cloudflare connector", () => diff --git a/apps/server/src/cloud/ManagedEndpointRuntime.ts b/apps/server/src/cloud/ManagedEndpointRuntime.ts index a1d7112a9..89c0a2378 100644 --- a/apps/server/src/cloud/ManagedEndpointRuntime.ts +++ b/apps/server/src/cloud/ManagedEndpointRuntime.ts @@ -72,7 +72,10 @@ export function classifyRelayClientOutput(line: string): "connected" | "warning" if (/\bRegistered tunnel connection\b/iu.test(line)) { return "connected"; } - return /\b(?:ERR|WRN)\b/u.test(line) ? "warning" : "debug"; + // cloudflared uses zerolog level tokens. FTL (fatal) and PNC (panic) are more + // severe than ERR, so they must surface at least as loudly — without them a + // fatal connector failure would be logged at debug and hidden. + return /\b(?:ERR|WRN|FTL|PNC)\b/u.test(line) ? "warning" : "debug"; } function runtimeConfigKey(config: RelayManagedEndpointRuntimeConfig): string { diff --git a/apps/server/src/cloud/bootService.test.ts b/apps/server/src/cloud/bootService.test.ts index 46e9a1da9..9af69eb17 100644 --- a/apps/server/src/cloud/bootService.test.ts +++ b/apps/server/src/cloud/bootService.test.ts @@ -1,558 +1,151 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; -import { assert, it } from "@effect/vitest"; +import { expect, it } from "@effect/vitest"; +import { + HostProcessArguments, + HostProcessExecutablePath, + HostProcessPlatform, +} from "@t3tools/shared/hostProcess"; import * as ConfigProvider from "effect/ConfigProvider"; 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 Schema from "effect/Schema"; import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; -import { - HostProcessArguments, - HostProcessExecutablePath, - HostProcessPlatform, -} from "@t3tools/shared/hostProcess"; - -import { reconcileService } from "../cli/service.ts"; import * as ProcessRunner from "../processRunner.ts"; import * as BootService from "./bootService.ts"; +import { pinnedRuntimePaths } from "./pinnedRuntime.ts"; +import { parseServiceState } from "./serviceProtocol.ts"; -const isUnsupportedError = Schema.is(BootService.BootServiceUnsupportedError); -const isCommandError = Schema.is(BootService.BootServiceCommandError); - -interface RecordedCommand { - readonly command: string; - readonly args: ReadonlyArray; -} - -const makeRecordingRunnerLayer = ( - commands: Array, - options?: { - readonly failCommand?: string; - readonly failWhen?: (command: string, args: ReadonlyArray) => boolean; - }, -) => - Layer.succeed( - ProcessRunner.ProcessRunner, - ProcessRunner.ProcessRunner.of({ - run: (input) => - Effect.sync(() => { - assert.isUndefined(input.env); - commands.push({ command: input.command, args: input.args }); - const failed = - input.command === options?.failCommand || - options?.failWhen?.(input.command, input.args) === true; - return { - stdout: "", - stderr: failed ? `${input.command} exploded` : "", - code: ChildProcessSpawner.ExitCode(failed ? 1 : 0), - timedOut: false, - stdoutTruncated: false, - stderrTruncated: false, - }; - }), - }), - ); - -const makeHost = (entry: string): BootService.BootServiceHost => ({ - execPath: "/usr/local/bin/node", - cliEntryPath: entry, -}); - -const provideHostRefs = (home: string, platform: NodeJS.Platform = "linux") => - Effect.provide( - Layer.mergeAll( - Layer.succeed(HostProcessPlatform, platform), - ConfigProvider.layer(ConfigProvider.fromEnv({ env: { HOME: home } })), - ), - ); - -const makeTestContext = Effect.fn("test.makeTestContext")(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-boot-service-test-" }); - // A real file for the stable-entry cases so status can confirm the entry - // point exists. - const stableEntry = path.join(root, "bin.mjs"); - yield* fs.writeFileString(stableEntry, "#!/usr/bin/env node\n"); - return { - fs, - path, - dirs: { - home: root, - baseDir: path.join(root, ".t3"), - logsDir: path.join(root, ".t3", "userdata", "logs"), - stableEntry, - }, - }; -}); - -it("renders a systemd unit with absolute paths and append-mode logging", () => { +it("keeps systemd pinned to the stable launcher rather than a versioned server", () => { const unit = BootService.renderBootServiceUnit({ - nodePath: "/usr/local/bin/node", - t3EntryPath: "/home/theo/.t3/runtime/versions/0.0.27/node_modules/t3/dist/bin.mjs", + nodePath: "/usr/bin/node", + launcherPath: "/home/theo/.t3/runtime/service-launcher.mjs", baseDir: "/home/theo/.t3", logPath: "/home/theo/.t3/userdata/logs/boot-service.log", unitPath: "/home/theo/.config/systemd/user/t3code.service", }); - assert.equal( - unit, - [ - "[Unit]", - "Description=T3 Code server", - "StartLimitIntervalSec=300", - "StartLimitBurst=5", - "", - "[Service]", - "Type=simple", - "WorkingDirectory=%h", - "Environment=T3CODE_HOME=/home/theo/.t3", - "Environment=T3_BOOT_SERVICE_UNIT=t3code.service", - "ExecStart=/usr/local/bin/node /home/theo/.t3/runtime/versions/0.0.27/node_modules/t3/dist/bin.mjs serve", - "Restart=always", - "RestartSec=5", - "StandardOutput=append:/home/theo/.t3/userdata/logs/boot-service.log", - "StandardError=append:/home/theo/.t3/userdata/logs/boot-service.log", - "", - "[Install]", - "WantedBy=default.target", - "", - ].join("\n"), - ); + expect(unit).toContain("ExecStart=/usr/bin/node /home/theo/.t3/runtime/service-launcher.mjs"); + expect(unit).toContain("KillMode=control-group"); + expect(unit).not.toContain("versions/1.2.3"); }); -it("quotes systemd values containing spaces and escapes percent specifiers", () => { - assert.equal(BootService.quoteSystemdValue("/plain/path"), "/plain/path"); - assert.equal(BootService.quoteSystemdValue("/home/me/T3 Data"), '"/home/me/T3 Data"'); - assert.equal(BootService.quoteSystemdValue("/opt/100%cpu"), "/opt/100%%cpu"); - - const unit = BootService.renderBootServiceUnit({ - nodePath: "/home/me/my tools/node", - t3EntryPath: "/home/me/T3 Data/bin.mjs", - baseDir: "/home/me/T3 Data", - logPath: "/home/me/100%logs/boot.log", - unitPath: "/home/me/.config/systemd/user/t3code.service", +const makeHarness = Effect.fn("test.make_boot_service_harness")(function* ( + platform: NodeJS.Platform = "linux", + usePinnedLauncher = false, +) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const home = yield* fs.makeTempDirectoryScoped({ prefix: "t3-boot-service-test-" }); + const baseDir = path.join(home, ".t3"); + const sourceLauncher = path.join(home, "service-launcher.mjs"); + const statePath = path.join(baseDir, "runtime", "service-state.json"); + yield* fs.writeFileString(sourceLauncher, "export {};\n"); + const runtime = pinnedRuntimePaths(path, baseDir, "1.2.3"); + yield* fs.makeDirectory(path.dirname(runtime.entryPath), { recursive: true }); + yield* fs.writeFileString(runtime.entryPath, "export {};\n"); + yield* fs.writeFileString( + path.join(path.dirname(runtime.entryPath), "service-launcher.mjs"), + "export const source = 'pinned runtime';\n", + ); + yield* fs.writeFileString(runtime.sentinelPath, "1.2.3\n"); + + const commands: string[] = []; + const control: { failCommand: string | undefined } = { failCommand: undefined }; + const runner = ProcessRunner.ProcessRunner.of({ + run: (input) => + Effect.sync(() => { + const command = `${input.command} ${input.args.join(" ")}`; + commands.push(command); + return { + stdout: input.args[1] === "--version" ? "t3 v1.2.3\n" : "", + stderr: "", + code: ChildProcessSpawner.ExitCode(command === control.failCommand ? 1 : 0), + timedOut: false, + stdoutTruncated: false, + stderrTruncated: false, + }; + }), }); - assert.include(unit, 'ExecStart="/home/me/my tools/node" "/home/me/T3 Data/bin.mjs" serve'); - assert.include(unit, 'Environment=T3CODE_HOME="/home/me/T3 Data"'); - // append: paths take the rest of the line literally (spaces are fine, - // quoting is not), but % still goes through specifier expansion. - assert.include(unit, "StandardOutput=append:/home/me/100%%logs/boot.log"); - assert.include(unit, "StandardError=append:/home/me/100%%logs/boot.log"); -}); - -it("flags package-manager cache entry points as ephemeral", () => { - assert.isTrue( - BootService.isEphemeralCacheEntry("/home/theo/.npm/_npx/abc123/node_modules/t3/dist/bin.mjs"), - ); - assert.isTrue( - BootService.isEphemeralCacheEntry("C:\\Users\\theo\\AppData\\npm-cache\\_npx\\abc\\bin.mjs"), - ); - assert.isTrue( - BootService.isEphemeralCacheEntry( - "/home/theo/.cache/pnpm/dlx/abc/node_modules/t3/dist/bin.mjs", - ), - ); - assert.isTrue( - BootService.isEphemeralCacheEntry("/home/theo/.bun/install/cache/t3@0.0.27/dist/bin.mjs"), - ); - assert.isFalse(BootService.isEphemeralCacheEntry("/usr/local/lib/node_modules/t3/dist/bin.mjs")); - assert.isFalse( - BootService.isEphemeralCacheEntry( - "/home/theo/dev/pnpm/dlx-tools/t3/node_modules/t3/dist/bin.mjs", - ), - ); - assert.isFalse( - BootService.isEphemeralCacheEntry( - "/home/theo/.t3/runtime/versions/0.0.27/node_modules/t3/dist/bin.mjs", + const service = yield* BootService.make({ + baseDir, + logsDir: path.join(baseDir, "userdata", "logs"), + cliVersion: "1.2.3", + host: { + execPath: "/usr/bin/node", + ...(usePinnedLauncher ? {} : { launcherSourcePath: sourceLauncher }), + }, + }).pipe( + Effect.provideService(ProcessRunner.ProcessRunner, runner), + Effect.provide( + Layer.mergeAll( + Layer.succeed(HostProcessPlatform, platform), + Layer.succeed(HostProcessExecutablePath, "/usr/bin/node"), + Layer.succeed(HostProcessArguments, ["/usr/bin/node", path.join(home, "bin.mjs")]), + ConfigProvider.layer(ConfigProvider.fromEnv({ env: { HOME: home } })), + ), ), ); + return { service, fs, statePath, commands, control }; }); -it.layer(NodeServices.layer)("BootService", (it) => { - it.effect("reconciles the standalone service once and is then idempotent", () => +it.layer(NodeServices.layer)("boot service install", (it) => { + it.effect("installs, reports current state, and uninstalls", () => Effect.gen(function* () { - const { dirs } = yield* makeTestContext(); - const commands: Array = []; - const service = yield* BootService.make({ - baseDir: dirs.baseDir, - logsDir: dirs.logsDir, - cliVersion: "0.0.27", - host: makeHost(dirs.stableEntry), - }).pipe(Effect.provide(makeRecordingRunnerLayer(commands)), provideHostRefs(dirs.home)); - - const first = yield* reconcileService().pipe( - Effect.provideService(BootService.BootService, service), - ); - assert.isTrue(first.changed); - if (!first.changed) return; - assert.isFalse(first.previouslyInstalled); - - const commandCount = commands.length; - const second = yield* reconcileService().pipe( - Effect.provideService(BootService.BootService, service), - ); - assert.isFalse(second.changed); - assert.lengthOf(commands, commandCount); - }), - ); - - it.effect("installs the unit, enables the service, and enables linger", () => - Effect.gen(function* () { - const { dirs, fs, path } = yield* makeTestContext(); - const commands: Array = []; - const service = yield* BootService.make({ - baseDir: dirs.baseDir, - logsDir: dirs.logsDir, - cliVersion: "0.0.27", - host: makeHost(dirs.stableEntry), - }).pipe(Effect.provide(makeRecordingRunnerLayer(commands)), provideHostRefs(dirs.home)); - + const { service, fs, statePath, commands } = yield* makeHarness(); const plan = yield* service.install; - // A stable entry point is reused directly — no npm install. - assert.equal(plan.t3EntryPath, dirs.stableEntry); - assert.deepEqual( - commands.map((entry) => [entry.command, ...entry.args].join(" ")), - [ - "systemctl --user daemon-reload", - "systemctl --user enable t3code.service", - // restart (not enable --now) so repairing a stale unit replaces a - // running process instead of leaving the old one until reboot. - "systemctl --user restart t3code.service", - "loginctl enable-linger", - ], - ); - - const unitPath = path.join(dirs.home, ".config", "systemd", "user", "t3code.service"); - const unit = yield* fs.readFileString(unitPath); - assert.include(unit, `ExecStart=/usr/local/bin/node ${dirs.stableEntry} serve`); - assert.include(unit, `Environment=T3CODE_HOME=${dirs.baseDir}`); - - const status = yield* service.status; - assert.isTrue(status.supported); - assert.isTrue(status.installed); - assert.isTrue(status.current); - - const removed = yield* service.uninstall; - assert.isTrue(removed); - assert.isFalse(yield* fs.exists(unitPath)); - const statusAfter = yield* service.status; - assert.isFalse(statusAfter.installed); - const removedAgain = yield* service.uninstall; - assert.isFalse(removedAgain); - }), - ); - - it.effect("pins a runtime via npm install when running from the npx cache", () => - Effect.gen(function* () { - const { dirs, fs, path } = yield* makeTestContext(); - const commands: Array = []; - const service = yield* BootService.make({ - baseDir: dirs.baseDir, - logsDir: dirs.logsDir, - cliVersion: "0.0.27", - host: makeHost("/home/theo/.npm/_npx/abc/node_modules/t3/dist/bin.mjs"), - }).pipe(Effect.provide(makeRecordingRunnerLayer(commands)), provideHostRefs(dirs.home)); - - const plan = yield* service.install; - - const runtimeDir = path.join(dirs.baseDir, "runtime", "versions", "0.0.27"); - assert.equal( - plan.t3EntryPath, - path.join(runtimeDir, "node_modules", "t3", "dist", "bin.mjs"), - ); - assert.deepEqual(commands[0], { - command: "npm", - args: ["install", "--prefix", runtimeDir, "--no-fund", "--no-audit", "t3@0.0.27"], + expect(parseServiceState(yield* fs.readFileString(statePath))).toEqual({ + protocol: 1, + activeVersion: "1.2.3", }); - // Success is recorded via a sentinel so interrupted installs re-run. - assert.isTrue(yield* fs.exists(path.join(runtimeDir, ".install-complete"))); - }), - ); - - it.effect("reinstalls a pinned runtime when its entry point is missing", () => - Effect.gen(function* () { - const { dirs, fs, path } = yield* makeTestContext(); - const commands: Array = []; - const service = yield* BootService.make({ - baseDir: dirs.baseDir, - logsDir: dirs.logsDir, - cliVersion: "0.0.27", - host: makeHost("/home/theo/.npm/_npx/abc/node_modules/t3/dist/bin.mjs"), - }).pipe(Effect.provide(makeRecordingRunnerLayer(commands)), provideHostRefs(dirs.home)); - - const plan = yield* service.install; - yield* fs.makeDirectory(path.dirname(plan.t3EntryPath), { recursive: true }); - yield* fs.writeFileString(plan.t3EntryPath, "#!/usr/bin/env node\n"); - yield* fs.remove(plan.t3EntryPath); - commands.length = 0; - - yield* service.install; - - assert.isTrue(commands.some(({ command }) => command === "npm")); - }), - ); - - it.effect("reads executable metadata from host process references", () => - Effect.gen(function* () { - const { dirs } = yield* makeTestContext(); - const commands: Array = []; - const service = yield* BootService.make({ - baseDir: dirs.baseDir, - logsDir: dirs.logsDir, - cliVersion: "0.0.27", - }).pipe( - Effect.provide(makeRecordingRunnerLayer(commands)), - provideHostRefs(dirs.home), - Effect.provideService(HostProcessExecutablePath, "/opt/node/bin/node"), - Effect.provideService(HostProcessArguments, ["/opt/node/bin/node", dirs.stableEntry]), - ); - - const plan = yield* service.install; - assert.equal(plan.nodePath, "/opt/node/bin/node"); - assert.equal(plan.t3EntryPath, dirs.stableEntry); - }), - ); - - it.effect("cleans up and fails when the pinned runtime install fails", () => - Effect.gen(function* () { - const { dirs, fs, path } = yield* makeTestContext(); - const commands: Array = []; - const service = yield* BootService.make({ - baseDir: dirs.baseDir, - logsDir: dirs.logsDir, - cliVersion: "0.0.27", - host: makeHost("/home/theo/.npm/_npx/abc/node_modules/t3/dist/bin.mjs"), - }).pipe( - Effect.provide(makeRecordingRunnerLayer(commands, { failCommand: "npm" })), - provideHostRefs(dirs.home), - ); - - const error = yield* service.install.pipe(Effect.flip); - assert.isTrue(isCommandError(error)); - const runtimeDir = path.join(dirs.baseDir, "runtime", "versions", "0.0.27"); - // The half-installed tree must not be reused by the next attempt. - assert.isFalse(yield* fs.exists(runtimeDir)); - assert.isFalse(yield* fs.exists(path.join(runtimeDir, ".install-complete"))); - }), - ); - - it.effect("reports an installed-but-stale unit so the lifecycle can offer a repair", () => - Effect.gen(function* () { - const { dirs, fs, path } = yield* makeTestContext(); - const commands: Array = []; - const service = yield* BootService.make({ - baseDir: dirs.baseDir, - logsDir: dirs.logsDir, - cliVersion: "0.0.27", - host: makeHost(dirs.stableEntry), - }).pipe(Effect.provide(makeRecordingRunnerLayer(commands)), provideHostRefs(dirs.home)); - - const unitDir = path.join(dirs.home, ".config", "systemd", "user"); - yield* fs.makeDirectory(unitDir, { recursive: true }); + expect(yield* fs.readFileString(plan.launcherPath)).toBe("export {};\n"); + expect((yield* service.status).current).toBe(true); yield* fs.writeFileString( - path.join(unitDir, "t3code.service"), - "[Service]\nExecStart=/old/node /old/t3 serve\n", + statePath, + '{"protocol":1,"activeVersion":"1.2.3","update":{"id":"u","fromVersion":"1.2.3","targetVersion":"1.2.4","status":"pending"}}', ); - - const status = yield* service.status; - assert.isTrue(status.supported); - assert.isTrue(status.installed); - assert.isFalse(status.current); - }), - ); - - it.effect("reports a current unit as stale when its entry point is gone", () => - Effect.gen(function* () { - const { dirs, fs } = yield* makeTestContext(); - const commands: Array = []; - const service = yield* BootService.make({ - baseDir: dirs.baseDir, - logsDir: dirs.logsDir, - cliVersion: "0.0.27", - host: makeHost(dirs.stableEntry), - }).pipe(Effect.provide(makeRecordingRunnerLayer(commands)), provideHostRefs(dirs.home)); - - yield* service.install; - assert.isTrue((yield* service.status).current); - - // The pinned runtime (or global bin) was deleted to reclaim space; the - // unit still matches byte-for-byte but would crashloop at boot. - yield* fs.remove(dirs.stableEntry); - const status = yield* service.status; - assert.isTrue(status.installed); - assert.isFalse(status.current); + expect((yield* service.status).current).toBe(false); + expect(yield* service.uninstall).toBe(true); + expect((yield* service.status).installed).toBe(false); + expect(commands.some((command) => command.startsWith("npm "))).toBe(false); }), ); - it.effect("fails on non-Linux platforms without touching the filesystem", () => + it.effect("copies the launcher from the prepared pinned runtime", () => Effect.gen(function* () { - const { dirs, fs, path } = yield* makeTestContext(); - const commands: Array = []; - const service = yield* BootService.make({ - baseDir: dirs.baseDir, - logsDir: dirs.logsDir, - cliVersion: "0.0.27", - host: makeHost("/usr/local/lib/node_modules/t3/dist/bin.mjs"), - }).pipe( - Effect.provide(makeRecordingRunnerLayer(commands)), - provideHostRefs(dirs.home, "darwin"), - ); + const { service, fs } = yield* makeHarness("linux", true); + const plan = yield* service.install; - const error = yield* service.install.pipe(Effect.flip); - assert.isTrue(isUnsupportedError(error)); - assert.lengthOf(commands, 0); - assert.isFalse( - yield* fs.exists(path.join(dirs.home, ".config", "systemd", "user", "t3code.service")), + expect(yield* fs.readFileString(plan.launcherPath)).toBe( + "export const source = 'pinned runtime';\n", ); - - const status = yield* service.status; - assert.isFalse(status.supported); - assert.isFalse(status.installed); }), ); - it.effect("removes the unit file when an activation step fails", () => + it.effect("restarts an installed service when repair fails", () => Effect.gen(function* () { - const { dirs, fs, path } = yield* makeTestContext(); - const commands: Array = []; - const service = yield* BootService.make({ - baseDir: dirs.baseDir, - logsDir: dirs.logsDir, - cliVersion: "0.0.27", - host: makeHost("/usr/local/lib/node_modules/t3/dist/bin.mjs"), - }).pipe( - Effect.provide(makeRecordingRunnerLayer(commands, { failCommand: "loginctl" })), - provideHostRefs(dirs.home), - ); + const { service, commands, control } = yield* makeHarness(); + yield* service.install; + commands.length = 0; + control.failCommand = "systemctl --user daemon-reload"; const error = yield* service.install.pipe(Effect.flip); - assert.isTrue(isCommandError(error)); - // A leftover unit would make status report "installed" even though - // linger never happened. - assert.isFalse( - yield* fs.exists(path.join(dirs.home, ".config", "systemd", "user", "t3code.service")), - ); - const status = yield* service.status; - assert.isFalse(status.installed); - assert.isTrue( - commands.some( - ({ command, args }) => - command === "systemctl" && args.join(" ") === "--user disable --now t3code.service", - ), - ); + expect(error._tag).toBe("BootServiceCommandError"); + expect(commands.filter((command) => command.startsWith("systemctl "))).toEqual([ + "systemctl --user stop t3code.service", + "systemctl --user daemon-reload", + "systemctl --user restart t3code.service", + ]); }), ); - it.effect("restores the previous unit when a repair cannot activate", () => + it.effect("fails closed off Linux", () => Effect.gen(function* () { - const { dirs, fs, path } = yield* makeTestContext(); - const initialCommands: Array = []; - const initialService = yield* BootService.make({ - baseDir: dirs.baseDir, - logsDir: dirs.logsDir, - cliVersion: "0.0.27", - host: makeHost(dirs.stableEntry), - }).pipe( - Effect.provide(makeRecordingRunnerLayer(initialCommands)), - provideHostRefs(dirs.home), - ); - yield* initialService.install; - - const unitPath = path.join(dirs.home, ".config", "systemd", "user", "t3code.service"); - const previousUnit = yield* fs.readFileString(unitPath); - const replacementEntry = path.join(dirs.home, "replacement-bin.mjs"); - yield* fs.writeFileString(replacementEntry, "#!/usr/bin/env node\n"); - const repairCommands: Array = []; - const repairService = yield* BootService.make({ - baseDir: dirs.baseDir, - logsDir: dirs.logsDir, - cliVersion: "0.0.28", - host: makeHost(replacementEntry), - }).pipe( - Effect.provide(makeRecordingRunnerLayer(repairCommands, { failCommand: "loginctl" })), - provideHostRefs(dirs.home), - ); - - const error = yield* repairService.install.pipe(Effect.flip); - - assert.isTrue(isCommandError(error)); - assert.equal(yield* fs.readFileString(unitPath), previousUnit); - assert.isTrue( - repairCommands.some( - ({ command, args }) => - command === "systemctl" && args.join(" ") === "--user restart t3code.service", - ), - ); - }), - ); - - it.effect("keeps the unit when stopping it during uninstall fails", () => - Effect.gen(function* () { - const { dirs, fs, path } = yield* makeTestContext(); - const installCommands: Array = []; - const installedService = yield* BootService.make({ - baseDir: dirs.baseDir, - logsDir: dirs.logsDir, - cliVersion: "0.0.27", - host: makeHost(dirs.stableEntry), - }).pipe( - Effect.provide(makeRecordingRunnerLayer(installCommands)), - provideHostRefs(dirs.home), - ); - yield* installedService.install; - - const uninstallCommands: Array = []; - const failingService = yield* BootService.make({ - baseDir: dirs.baseDir, - logsDir: dirs.logsDir, - cliVersion: "0.0.27", - host: makeHost(dirs.stableEntry), - }).pipe( - Effect.provide( - makeRecordingRunnerLayer(uninstallCommands, { - failWhen: (command, args) => - command === "systemctl" && args.includes("disable") && args.includes("--now"), - }), - ), - provideHostRefs(dirs.home), - ); - - const error = yield* failingService.uninstall.pipe(Effect.flip); - - assert.isTrue(isCommandError(error)); - assert.isTrue( - yield* fs.exists(path.join(dirs.home, ".config", "systemd", "user", "t3code.service")), - ); - }), - ); - - it.effect("appends failed steps to the boot-service log", () => - Effect.gen(function* () { - const { dirs, fs, path } = yield* makeTestContext(); - const commands: Array = []; - const service = yield* BootService.make({ - baseDir: dirs.baseDir, - logsDir: dirs.logsDir, - cliVersion: "0.0.27", - host: makeHost("/usr/local/lib/node_modules/t3/dist/bin.mjs"), - }).pipe( - Effect.provide(makeRecordingRunnerLayer(commands, { failCommand: "systemctl" })), - provideHostRefs(dirs.home), - ); - - const error = yield* service.install.pipe(Effect.flip); - assert.isTrue(isCommandError(error)); - if (!isCommandError(error)) return; - assert.equal(error.exitCode, 1); - assert.equal(error.stderrLength, "systemctl exploded".length); - - const logPath = path.join(dirs.logsDir, "boot-service.log"); - assert.isTrue(yield* fs.exists(logPath)); - assert.include(yield* fs.readFileString(logPath), "exit code 1"); + const { service } = yield* makeHarness("darwin"); + expect((yield* service.status).supported).toBe(false); + expect((yield* service.install.pipe(Effect.flip))._tag).toBe("BootServiceUnsupportedError"); }), ); }); diff --git a/apps/server/src/cloud/bootService.ts b/apps/server/src/cloud/bootService.ts index d7e13e834..9a8481b11 100644 --- a/apps/server/src/cloud/bootService.ts +++ b/apps/server/src/cloud/bootService.ts @@ -1,5 +1,6 @@ -import * as Context from "effect/Context"; +import { HostProcessExecutablePath, HostProcessPlatform } 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"; @@ -9,58 +10,29 @@ import * as Option from "effect/Option"; import * as Path from "effect/Path"; import * as Schema from "effect/Schema"; -import { - HostProcessArguments, - HostProcessExecutablePath, - HostProcessPlatform, -} from "@t3tools/shared/hostProcess"; - import * as ProcessRunner from "../processRunner.ts"; -import { ensurePinnedRuntimeInstalled, pinnedRuntimePaths } from "./pinnedRuntime.ts"; - -/** - * Installs T3 Code as a per-user boot service. Linux-only for now: systemd - * user unit + loginctl enable-linger. The service runs a stable or pinned - * runtime — never an ephemeral `npx t3` cache whose eviction could break - * startup. - */ +import { + ensurePinnedRuntimeInstalled, + pinnedRuntimePaths, + PinnedRuntimeInstallError, +} from "./pinnedRuntime.ts"; +import { + SERVICE_LAUNCHER_FILE, + SERVICE_LAUNCHER_PROTOCOL, + SERVICE_STATE_FILE, + parseServiceState, + type ServiceState, +} from "./serviceProtocol.ts"; const BOOT_SERVICE_NAME = "t3code"; - export const BOOT_SERVICE_UNIT_FILE = `${BOOT_SERVICE_NAME}.service`; export const BOOT_SERVICE_UNIT_ENV = "T3_BOOT_SERVICE_UNIT"; -const EPHEMERAL_CACHE_SEGMENTS = [ - "/_npx/", // npx - "\\_npx\\", - "/pnpm/dlx/", // pnpm dlx (~/.cache/pnpm/dlx and $PNPM_HOME/.pnpm/dlx) - "/.pnpm/dlx/", - "/.bun/install/cache/", // bunx -]; - -/** - * `npx t3` (and pnpm dlx / bunx) run out of ephemeral package-manager - * caches that can be evicted at any time — a boot service must never point - * there. Global installs, repo checkouts, and the pinned runtime below are - * all stable. - */ -export function isEphemeralCacheEntry(entryPath: string): boolean { - return EPHEMERAL_CACHE_SEGMENTS.some((segment) => entryPath.includes(segment)); -} - -/** - * systemd expands `%` specifiers in most directive values, including the - * `append:` file paths, which take the rest of the line literally and must - * NOT be quoted. - */ +/** systemd expands `%` specifiers, including in unquoted append-log paths. */ export function escapeSystemdSpecifiers(value: string): string { return value.replaceAll("%", "%%"); } -/** - * systemd word-splits ExecStart and Environment values and expands `%` - * specifiers, so paths with spaces or percents must be quoted and escaped. - */ export function quoteSystemdValue(value: string): string { const escaped = escapeSystemdSpecifiers(value); return /[\s"'\\]/.test(escaped) @@ -69,31 +41,19 @@ export function quoteSystemdValue(value: string): string { } export interface BootServicePlan { - /** Absolute path of the node binary running this CLI. */ readonly nodePath: string; - /** Absolute path of the pinned t3 entry point the unit will run. */ - readonly t3EntryPath: string; + readonly launcherPath: string; readonly baseDir: string; readonly logPath: string; readonly unitPath: string; } -/** - * Pure so it is testable byte-for-byte. systemd user units run with a - * minimal environment: every path must be absolute, and the service must - * not rely on PATH, nvm shims, or shell profiles. Failures land in - * `logPath` because `systemctl --user` failures are otherwise invisible. - */ +/** Pure renderer: service units cannot rely on the user's shell or PATH. */ export function renderBootServiceUnit(plan: BootServicePlan): string { - // No After=network-online.target: it does not exist in the systemd *user* - // manager, so ordering on it is silently ignored. The server retries its - // relay connection, and Restart=always covers early-boot failures. + // The user manager has no reliable network-online target; server networking retries itself. return [ "[Unit]", "Description=T3 Code server", - // Give up after 5 crashes in 5 minutes so a persistently broken install - // (deleted runtime, broken workspace) stops instead of restarting every - // 5s forever and growing the unrotated append log without bound. "StartLimitIntervalSec=300", "StartLimitBurst=5", "", @@ -102,7 +62,8 @@ export function renderBootServiceUnit(plan: BootServicePlan): string { "WorkingDirectory=%h", `Environment=T3CODE_HOME=${quoteSystemdValue(plan.baseDir)}`, `Environment=${BOOT_SERVICE_UNIT_ENV}=${BOOT_SERVICE_UNIT_FILE}`, - `ExecStart=${quoteSystemdValue(plan.nodePath)} ${quoteSystemdValue(plan.t3EntryPath)} serve`, + `ExecStart=${quoteSystemdValue(plan.nodePath)} ${quoteSystemdValue(plan.launcherPath)}`, + "KillMode=control-group", "Restart=always", "RestartSec=5", `StandardOutput=append:${escapeSystemdSpecifiers(plan.logPath)}`, @@ -157,7 +118,6 @@ export type BootServiceError = export interface BootServiceStatus { readonly supported: boolean; readonly installed: boolean; - /** False when the installed unit no longer matches what install would write. */ readonly current: boolean; readonly unitPath: string; readonly logPath: string; @@ -166,12 +126,7 @@ export interface BootServiceStatus { export class BootService extends Context.Service< BootService, { - /** Installs the pinned runtime + unit, enables linger, starts the service. */ readonly install: Effect.Effect; - /** - * Stops and removes the unit; leaves the pinned runtime for reuse. - * Returns whether a unit was actually removed. - */ readonly uninstall: Effect.Effect; readonly status: Effect.Effect; } @@ -179,7 +134,7 @@ export class BootService extends Context.Service< export interface BootServiceHost { readonly execPath: string; - readonly cliEntryPath: string; + readonly launcherSourcePath?: string; } export const make = Effect.fn("cloud.boot_service.make")(function* (input: { @@ -189,23 +144,41 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: { readonly host?: BootServiceHost; }) { const hostExecPath = yield* HostProcessExecutablePath; - const hostArguments = yield* HostProcessArguments; - const host = input.host ?? { - execPath: hostExecPath, - // When running the packed CLI this is dist/bin.mjs; when stable (global - // install, repo checkout) the boot service runs this same artifact. - cliEntryPath: hostArguments[1] ?? "", - }; const platform = yield* HostProcessPlatform; const homeDir = yield* Config.string("HOME").pipe(Config.withDefault("")); const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; const runner = yield* ProcessRunner.ProcessRunner; + const host = input.host ?? { execPath: hostExecPath }; const unitDir = path.join(homeDir, ".config", "systemd", "user"); const unitPath = path.join(unitDir, BOOT_SERVICE_UNIT_FILE); const logPath = path.join(input.logsDir, "boot-service.log"); + const launcherPath = path.join(input.baseDir, "runtime", SERVICE_LAUNCHER_FILE); + const statePath = path.join(input.baseDir, "runtime", SERVICE_STATE_FILE); const runtimePaths = pinnedRuntimePaths(path, input.baseDir, input.cliVersion); + const launcherSourcePath = + host.launcherSourcePath ?? + path.join(path.dirname(runtimePaths.entryPath), SERVICE_LAUNCHER_FILE); + const writeDurably = (filePath: string, contents: string) => + Effect.scoped( + Effect.gen(function* () { + const directory = path.dirname(filePath); + yield* fs.makeDirectory(directory, { recursive: true }); + const tempPath = yield* fs.makeTempFileScoped({ directory, prefix: ".service-write-" }); + yield* fs.writeFileString(tempPath, contents, { mode: 0o600 }); + yield* (yield* fs.open(tempPath, { flag: "r" })).sync; + yield* fs.rename(tempPath, filePath); + yield* (yield* fs.open(directory, { flag: "r" })).sync; + }), + ).pipe(Effect.mapError((cause) => new BootServiceInstallError({ cause }))); + const plan: BootServicePlan = { + nodePath: host.execPath, + launcherPath, + baseDir: input.baseDir, + logPath, + unitPath, + }; const requireSystemdLinux = Effect.gen(function* () { if (platform !== "linux" || homeDir === "") { @@ -244,150 +217,130 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: { ); }); - /** - * Ensures plannedEntryPath exists before the unit points at it. A stable - * install (global bin, repo checkout) is used as-is; an ephemeral cache - * entry is replaced by `npm install --prefix`-ing the exact running - * version into /runtime/versions/. A real install (not a copy - * of bin.mjs) because t3 ships native deps like node-pty. - */ - const ensurePinnedRuntime = Effect.gen(function* () { - if (!isEphemeralCacheEntry(host.cliEntryPath)) { - return; - } + const install: BootService["Service"]["install"] = Effect.gen(function* () { + yield* requireSystemdLinux; + yield* fs + .makeDirectory(input.logsDir, { recursive: true }) + .pipe(Effect.mapError((cause) => new BootServiceInstallError({ cause }))); + + // Prepare every immutable artifact before stopping the installed unit. yield* ensurePinnedRuntimeInstalled({ baseDir: input.baseDir, version: input.cliVersion, fs, path, runner, + validate: (runtime) => + runner + .run({ + command: host.execPath, + args: [runtime.entryPath, "--version"], + timeout: Duration.seconds(30), + }) + .pipe( + Effect.mapError( + (cause) => + new PinnedRuntimeInstallError({ + step: "verifying the pinned t3 runtime", + cause, + }), + ), + Effect.flatMap((result) => { + const reportedVersion = /\bv(\S+)\s*$/.exec(result.stdout)?.[1]; + return result.code === 0 && reportedVersion === input.cliVersion + ? Effect.void + : Effect.fail( + new PinnedRuntimeInstallError({ + step: "verifying the pinned t3 runtime", + exitCode: Number(result.code), + stdoutLength: result.stdout.length, + stderrLength: result.stderr.length, + }), + ); + }), + ), }).pipe( Effect.mapError((error) => - error.step.startsWith("installing") + error._tag === "PinnedRuntimeInstallError" ? new BootServiceCommandError({ step: error.step, exitCode: error.exitCode, stdoutLength: error.stdoutLength, stderrLength: error.stderrLength, - cause: error.cause, + cause: error, }) : new BootServiceInstallError({ cause: error }), ), - Effect.tapError((error) => - DateTime.now.pipe( - Effect.flatMap((now) => - fs.writeFileString(logPath, `${DateTime.formatIso(now)} ${error.message}\n`, { - flag: "a", - }), - ), - Effect.ignore, - ), - ), ); - }); - - // Where the unit will point: derivable without touching the network, so - // status can compare units purely; install materializes it first. - const plannedEntryPath = isEphemeralCacheEntry(host.cliEntryPath) - ? runtimePaths.entryPath - : host.cliEntryPath; - const plan: BootServicePlan = { - nodePath: host.execPath, - t3EntryPath: plannedEntryPath, - baseDir: input.baseDir, - logPath, - unitPath, - }; - - const install: BootService["Service"]["install"] = Effect.gen(function* () { - yield* requireSystemdLinux; - yield* fs - .makeDirectory(input.logsDir, { recursive: true }) + const launcherSource = yield* fs + .readFileString(launcherSourcePath) .pipe(Effect.mapError((cause) => new BootServiceInstallError({ cause }))); - yield* ensurePinnedRuntime; - - const previousUnit = yield* fs.exists(unitPath).pipe( - Effect.flatMap((exists) => - exists - ? fs.readFileString(unitPath).pipe(Effect.map(Option.some)) - : Effect.succeed(Option.none()), - ), - Effect.mapError((cause) => new BootServiceInstallError({ cause })), - ); - - yield* fs.makeDirectory(unitDir, { recursive: true }).pipe( - Effect.andThen(fs.writeFileString(unitPath, renderBootServiceUnit(plan))), - Effect.mapError((cause) => new BootServiceInstallError({ cause })), - ); + const installed = yield* fs + .exists(unitPath) + .pipe(Effect.mapError((cause) => new BootServiceInstallError({ cause }))); + if (installed) { + yield* runStep("stopping the installed service", "systemctl", [ + "--user", + "stop", + BOOT_SERVICE_UNIT_FILE, + ]); + } - // If any activation step fails, remove the unit again: a leftover file - // would make service status report it as installed even though it was - // never enabled or lingered. yield* Effect.gen(function* () { + yield* fs + .makeDirectory(unitDir, { recursive: true }) + .pipe(Effect.mapError((cause) => new BootServiceInstallError({ cause }))); + yield* writeDurably(launcherPath, launcherSource); + yield* writeDurably( + statePath, + // @effect-diagnostics-next-line preferSchemaOverJson:off - fixed launcher-owned document. + `${JSON.stringify( + { + protocol: SERVICE_LAUNCHER_PROTOCOL, + activeVersion: input.cliVersion, + } satisfies ServiceState, + null, + 2, + )}\n`, + ); + yield* writeDurably(unitPath, renderBootServiceUnit(plan)); + yield* runStep("reloading systemd user units", "systemctl", ["--user", "daemon-reload"]); yield* runStep("enabling the service", "systemctl", [ "--user", "enable", BOOT_SERVICE_UNIT_FILE, ]); - // restart rather than enable --now: --now does not replace an already - // running process, so repairing a stale unit would leave the old - // server running until reboot. restart also starts a stopped service. + yield* runStep("enabling lingering for this user", "loginctl", ["enable-linger"]); + // Start last. No administrative state write occurs after this succeeds. yield* runStep("starting the service", "systemctl", [ "--user", "restart", BOOT_SERVICE_UNIT_FILE, ]); - // Linger keeps the user manager (and this service) running without an - // open session — the whole point on a box reached over SSH. No - // username argument: loginctl defaults to the calling user, which is - // always right, while $USER can be stale (su without -l) or unset. - yield* runStep("enabling lingering for this user", "loginctl", ["enable-linger"]); - }).pipe(Effect.tapError(() => rollbackFailedInstall(previousUnit))); - + }).pipe( + Effect.tapError(() => + installed + ? runStep("restarting the service after a failed update", "systemctl", [ + "--user", + "restart", + BOOT_SERVICE_UNIT_FILE, + ]).pipe(Effect.ignore) + : Effect.void, + ), + ); return plan; }).pipe(Effect.withSpan("cloud.boot_service.install")); - // If activation fails partway (e.g. enable succeeds but restart/linger - // fails), leave nothing behind: disable removes the enable symlink, remove - // deletes the file, daemon-reload clears the stale definition — otherwise a - // dangling wants/ symlink logs "Failed to load unit" at every boot and the - // next lifecycle command misreports the state. - const rollbackFailedInstall = Effect.fn("cloud.boot_service.rollback_failed_install")(function* ( - previousUnit: Option.Option, - ) { - if (Option.isSome(previousUnit)) { - yield* fs.writeFileString(unitPath, previousUnit.value).pipe(Effect.ignore); - } else { - yield* runStep("cleaning up the service", "systemctl", [ - "--user", - "disable", - "--now", - BOOT_SERVICE_UNIT_FILE, - ]).pipe(Effect.ignore); - yield* fs.remove(unitPath).pipe(Effect.ignore); - } - yield* runStep("reloading systemd user units", "systemctl", ["--user", "daemon-reload"]).pipe( - Effect.ignore, - ); - if (Option.isSome(previousUnit)) { - yield* runStep("restoring the previous service", "systemctl", [ - "--user", - "restart", - BOOT_SERVICE_UNIT_FILE, - ]).pipe(Effect.ignore); - } - }); - const uninstall: BootService["Service"]["uninstall"] = Effect.gen(function* () { yield* requireSystemdLinux; - const exists = yield* fs - .exists(unitPath) - .pipe(Effect.mapError((cause) => new BootServiceInstallError({ cause }))); - if (!exists) { + if ( + !(yield* fs + .exists(unitPath) + .pipe(Effect.mapError((cause) => new BootServiceInstallError({ cause })))) + ) return false; - } yield* runStep("stopping the service", "systemctl", [ "--user", "disable", @@ -405,18 +358,32 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: { if (platform !== "linux" || homeDir === "") { return { supported: false, installed: false, current: false, unitPath, logPath }; } - const unitExists = yield* fs.exists(unitPath); - if (!unitExists) { + if (!(yield* fs.exists(unitPath))) { return { supported: true, installed: false, current: false, unitPath, logPath }; } - const unit = yield* fs.readFileString(unitPath); - // A unit is current only if it matches what install would write now (an - // older CLI wrote a different runtime/node path) AND the entry point it - // references still exists (a pinned runtime under ~/.t3 can be deleted to - // reclaim space). Either mismatch makes connect offer a repair. - const entryExists = yield* fs.exists(plannedEntryPath); - const current = unit === renderBootServiceUnit(plan) && entryExists; - return { supported: true, installed: true, current, unitPath, logPath }; + const [unit, launcherExists, runtimeEntryExists, runtimeSentinel, stateText] = + yield* Effect.all([ + fs.readFileString(unitPath), + fs.exists(launcherPath), + fs.exists(runtimePaths.entryPath), + fs.readFileString(runtimePaths.sentinelPath).pipe(Effect.option), + fs.readFileString(statePath).pipe(Effect.option), + ]); + const state = Option.isSome(stateText) ? parseServiceState(stateText.value) : undefined; + return { + supported: true, + installed: true, + current: + unit === renderBootServiceUnit(plan) && + launcherExists && + runtimeEntryExists && + Option.isSome(runtimeSentinel) && + runtimeSentinel.value.trim() === input.cliVersion && + state?.activeVersion === input.cliVersion && + state?.update?.status !== "pending", + unitPath, + logPath, + }; }).pipe( Effect.mapError((cause) => new BootServiceInstallError({ cause })), Effect.withSpan("cloud.boot_service.status"), diff --git a/apps/server/src/cloud/pinnedRuntime.test.ts b/apps/server/src/cloud/pinnedRuntime.test.ts index 9b46c0038..e6ec99e7e 100644 --- a/apps/server/src/cloud/pinnedRuntime.test.ts +++ b/apps/server/src/cloud/pinnedRuntime.test.ts @@ -2,8 +2,8 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, it } from "@effect/vitest"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; -import * as Fiber from "effect/Fiber"; import * as FileSystem from "effect/FileSystem"; +import * as Fiber from "effect/Fiber"; import * as Path from "effect/Path"; import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; @@ -11,119 +11,164 @@ import * as ProcessRunner from "../processRunner.ts"; import { ensurePinnedRuntimeInstalled, pinnedRuntimePaths, - removePinnedRuntimeInstallation, + PinnedRuntimeInstallError, } from "./pinnedRuntime.ts"; +const successfulRunner = (fs: FileSystem.FileSystem, path: Path.Path) => + ProcessRunner.ProcessRunner.of({ + run: (input) => + Effect.gen(function* () { + const prefixIndex = input.args.indexOf("--prefix"); + const stagingDir = input.args[prefixIndex + 1]; + if (stagingDir === undefined) return yield* Effect.die("missing npm --prefix"); + const entry = path.join(stagingDir, "node_modules", "t3", "dist", "bin.mjs"); + yield* fs.makeDirectory(path.dirname(entry), { recursive: true }).pipe(Effect.orDie); + yield* fs.writeFileString(entry, "export {};\n").pipe(Effect.orDie); + return { + stdout: "", + stderr: "", + code: ChildProcessSpawner.ExitCode(0), + timedOut: false, + stdoutTruncated: false, + stderrTruncated: false, + }; + }), + }); + it.layer(NodeServices.layer)("ensurePinnedRuntimeInstalled", (it) => { - it.effect("serializes concurrent installs of the same runtime", () => + it.effect("validates a staging tree before atomically publishing it", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-pinned-runtime-test-" }); - const installStarted = yield* Deferred.make(); - const allowInstallToFinish = yield* Deferred.make(); - const paths = pinnedRuntimePaths(path, baseDir, "0.0.29"); - let npmRuns = 0; + const finalPaths = pinnedRuntimePaths(path, baseDir, "1.2.3"); + let validatedDirectory = ""; - const runner = ProcessRunner.ProcessRunner.of({ - run: (_input) => + const installed = yield* ensurePinnedRuntimeInstalled({ + baseDir, + version: "1.2.3", + fs, + path, + runner: successfulRunner(fs, path), + validate: (staging) => Effect.gen(function* () { - npmRuns += 1; - yield* Deferred.succeed(installStarted, undefined); - yield* Deferred.await(allowInstallToFinish); - yield* fs - .makeDirectory(path.dirname(paths.entryPath), { recursive: true }) - .pipe(Effect.orDie); - yield* fs.writeFileString(paths.entryPath, "export {};\n").pipe(Effect.orDie); - return { - stdout: "", - stderr: "", - code: ChildProcessSpawner.ExitCode(0), - timedOut: false, - stdoutTruncated: false, - stderrTruncated: false, - }; - }), + validatedDirectory = staging.versionDir; + assert.isFalse(yield* fs.exists(finalPaths.versionDir)); + assert.isTrue(yield* fs.exists(staging.entryPath)); + }).pipe(Effect.orDie), }); - const install = ensurePinnedRuntimeInstalled({ + + assert.notEqual(validatedDirectory, finalPaths.versionDir); + assert.deepEqual(installed, finalPaths); + assert.isTrue(yield* fs.exists(finalPaths.entryPath)); + assert.equal(yield* fs.readFileString(finalPaths.sentinelPath), "1.2.3\n"); + }), + ); + + it.effect("removes staging and leaves no final runtime when validation fails", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-pinned-runtime-test-" }); + const finalPaths = pinnedRuntimePaths(path, baseDir, "1.2.3"); + + yield* ensurePinnedRuntimeInstalled({ baseDir, - version: "0.0.29", + version: "1.2.3", fs, path, - runner, - }); + runner: successfulRunner(fs, path), + validate: () => + Effect.fail(new PinnedRuntimeInstallError({ step: "validating the staged runtime" })), + }).pipe(Effect.flip); - const first = yield* Effect.forkChild(install, { startImmediately: true }); - yield* Deferred.await(installStarted); - const second = yield* Effect.forkChild(install, { startImmediately: true }); - yield* Effect.yieldNow; - assert.equal(npmRuns, 1); + assert.isFalse(yield* fs.exists(finalPaths.versionDir)); + assert.deepEqual( + (yield* fs.readDirectory(path.dirname(finalPaths.versionDir))).filter((entry) => + entry.startsWith(".staging-"), + ), + [], + ); + }), + ); - yield* Deferred.succeed(allowInstallToFinish, undefined); - yield* Fiber.join(first); - yield* Fiber.join(second); + it.effect("replaces an incomplete pinned runtime", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-pinned-runtime-repair-" }); + const finalPaths = pinnedRuntimePaths(path, baseDir, "1.2.3"); + yield* fs.makeDirectory(finalPaths.versionDir, { recursive: true }); + yield* fs.writeFileString(path.join(finalPaths.versionDir, "partial"), "incomplete\n"); - assert.equal(npmRuns, 1); - assert.isTrue(yield* fs.exists(paths.sentinelPath)); - assert.isTrue(yield* fs.exists(paths.entryPath)); + yield* ensurePinnedRuntimeInstalled({ + baseDir, + version: "1.2.3", + fs, + path, + runner: successfulRunner(fs, path), + validate: () => Effect.void, + }); + + assert.isFalse(yield* fs.exists(path.join(finalPaths.versionDir, "partial"))); + assert.isTrue(yield* fs.exists(finalPaths.entryPath)); }), ); - it.effect("waits for an active install before removing its runtime", () => + it.effect("preserves a completed runtime when validation fails", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; - const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-pinned-runtime-test-" }); - const installStarted = yield* Deferred.make(); - const allowInstallToFinish = yield* Deferred.make(); - const paths = pinnedRuntimePaths(path, baseDir, "0.0.30"); - const runner = ProcessRunner.ProcessRunner.of({ - run: (_input) => + const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-pinned-runtime-repair-" }); + const finalPaths = pinnedRuntimePaths(path, baseDir, "1.2.3"); + yield* fs.makeDirectory(path.dirname(finalPaths.entryPath), { recursive: true }); + yield* fs.writeFileString(finalPaths.entryPath, "broken\n"); + yield* fs.writeFileString(finalPaths.sentinelPath, "1.2.3\n"); + + let validations = 0; + yield* ensurePinnedRuntimeInstalled({ + baseDir, + version: "1.2.3", + fs, + path, + runner: successfulRunner(fs, path), + validate: (paths) => Effect.gen(function* () { - yield* Deferred.succeed(installStarted, undefined); - yield* Deferred.await(allowInstallToFinish); - yield* fs - .makeDirectory(path.dirname(paths.entryPath), { recursive: true }) - .pipe(Effect.orDie); - yield* fs.writeFileString(paths.entryPath, "export {};\n").pipe(Effect.orDie); - return { - stdout: "", - stderr: "", - code: ChildProcessSpawner.ExitCode(0), - timedOut: false, - stdoutTruncated: false, - stderrTruncated: false, - }; + validations += 1; + const source = yield* fs.readFileString(paths.entryPath).pipe(Effect.orDie); + if (source === "broken\n") { + return yield* new PinnedRuntimeInstallError({ step: "validating the runtime" }); + } }), - }); + }).pipe(Effect.flip); - const installFiber = yield* Effect.forkChild( - ensurePinnedRuntimeInstalled({ - baseDir, - version: "0.0.30", - fs, - path, - runner, - }), - { startImmediately: true }, - ); - yield* Deferred.await(installStarted); - const removeFiber = yield* Effect.forkChild( - removePinnedRuntimeInstallation({ - baseDir, - version: "0.0.30", - fs, - path, - }), - { startImmediately: true }, - ); - yield* Effect.yieldNow; - assert.isTrue(yield* fs.exists(paths.versionDir)); + assert.equal(validations, 1); + assert.equal(yield* fs.readFileString(finalPaths.entryPath), "broken\n"); + }), + ); + + it.effect("removes staging when installation is interrupted", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-pinned-runtime-interrupt-" }); + const started = yield* Deferred.make(); + const runner = ProcessRunner.ProcessRunner.of({ + run: () => Deferred.succeed(started, undefined).pipe(Effect.andThen(Effect.never)), + }); + const install = yield* ensurePinnedRuntimeInstalled({ + baseDir, + version: "1.2.3", + fs, + path, + runner, + validate: () => Effect.void, + }).pipe(Effect.forkScoped); - yield* Deferred.succeed(allowInstallToFinish, undefined); - yield* Fiber.join(installFiber); - yield* Fiber.join(removeFiber); - assert.isFalse(yield* fs.exists(paths.versionDir)); + yield* Deferred.await(started); + yield* Fiber.interrupt(install); + const versionsDir = path.join(baseDir, "runtime", "versions"); + assert.deepEqual(yield* fs.readDirectory(versionsDir), []); }), ); }); diff --git a/apps/server/src/cloud/pinnedRuntime.ts b/apps/server/src/cloud/pinnedRuntime.ts index e3e095ce0..ba3b380b0 100644 --- a/apps/server/src/cloud/pinnedRuntime.ts +++ b/apps/server/src/cloud/pinnedRuntime.ts @@ -3,6 +3,7 @@ import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Path from "effect/Path"; import * as Schema from "effect/Schema"; +import * as Option from "effect/Option"; import * as Semaphore from "effect/Semaphore"; import * as ProcessRunner from "../processRunner.ts"; @@ -11,15 +12,14 @@ import * as ProcessRunner from "../processRunner.ts"; * A pinned runtime is an exact `t3@` npm-installed into * /runtime/versions/. The boot service points its systemd * unit here, and server self-update installs the target version here before - * switching over — never `npx t3`, whose cache is ephemeral and whose + * switching over, never `npx t3`, whose cache is ephemeral and whose * registry fetch at boot would make startup depend on the network. */ const PINNED_RUNTIME_DIR = "runtime"; const PINNED_RUNTIME_INSTALL_TIMEOUT = Duration.minutes(10); -// Boot-service setup and remote self-update share this module but can be -// constructed in separate layers. Serialize the complete check/install/ -// sentinel transaction across all callers in this process. +// Boot-service setup and remote update can construct separate layers. Serialize +// the complete install transaction across every caller in this process. const pinnedRuntimeInstallLock = Semaphore.makeUnsafe(1); export interface PinnedRuntimePaths { @@ -58,119 +58,165 @@ export class PinnedRuntimeInstallError extends Schema.TaggedErrorClass()( + "PinnedRuntimePreflightBlockedError", + { + version: Schema.String, + reason: Schema.String, + }, +) { + override get message(): string { + return this.reason; + } +} + /** * Installs `t3@` into the pinned runtime directory unless a complete * install is already there, and returns its paths. The sentinel is written - * only after npm exits 0; checking the entry file alone is not enough — npm + * only after npm exits 0; checking the entry file alone is not enough. npm * extracts files before running native builds (node-pty), so a killed * install leaves a plausible-looking but broken tree behind. */ -export const ensurePinnedRuntimeInstalled = Effect.fn("cloud.pinned_runtime.ensure_installed")( - function* (input: { - readonly baseDir: string; - readonly version: string; - readonly fs: FileSystem.FileSystem; - readonly path: Path.Path; - readonly runner: ProcessRunner.ProcessRunner["Service"]; - }) { - const { fs, runner } = input; - const paths = pinnedRuntimePaths(input.path, input.baseDir, input.version); +interface PinnedRuntimeInstallInput { + readonly baseDir: string; + readonly version: string; + readonly fs: FileSystem.FileSystem; + readonly path: Path.Path; + readonly runner: ProcessRunner.ProcessRunner["Service"]; + readonly validate: ( + paths: PinnedRuntimePaths, + ) => Effect.Effect; +} + +const installPinnedRuntime = Effect.fn("cloud.pinned_runtime.ensure_installed")(function* ( + input: PinnedRuntimeInstallInput, +) { + const { fs, runner } = input; + const paths = pinnedRuntimePaths(input.path, input.baseDir, input.version); + const [versionDirExists, entryExists, sentinel] = yield* Effect.all([ + fs.exists(paths.versionDir), + fs.exists(paths.entryPath), + fs.readFileString(paths.sentinelPath).pipe(Effect.option), + ]).pipe( + Effect.mapError( + (cause) => new PinnedRuntimeInstallError({ step: "checking the pinned runtime", cause }), + ), + ); + const alreadyPinned = + entryExists && Option.isSome(sentinel) && sentinel.value.trim() === input.version; + if (alreadyPinned) { + yield* input.validate(paths); + return paths; + } + if (versionDirExists) { + yield* fs.remove(paths.versionDir, { recursive: true, force: true }).pipe( + Effect.mapError( + (cause) => + new PinnedRuntimeInstallError({ + step: "removing an incomplete pinned runtime", + cause, + }), + ), + ); + } + + const versionsDir = input.path.dirname(paths.versionDir); + yield* fs.makeDirectory(versionsDir, { recursive: true }).pipe( + Effect.mapError( + (cause) => + new PinnedRuntimeInstallError({ + step: "preparing the pinned runtime directory", + cause, + }), + ), + ); + const stagingDir = yield* fs + .makeTempDirectory({ + directory: versionsDir, + prefix: ".staging-", + }) + .pipe( + Effect.mapError( + (cause) => + new PinnedRuntimeInstallError({ + step: "preparing the pinned runtime directory", + cause, + }), + ), + ); + const stagingPaths: PinnedRuntimePaths = { + versionDir: stagingDir, + entryPath: input.path.join(stagingDir, "node_modules", "t3", "dist", "bin.mjs"), + sentinelPath: input.path.join(stagingDir, ".install-complete"), + }; - return yield* pinnedRuntimeInstallLock.withPermit( - Effect.gen(function* () { - const alreadyPinned = yield* Effect.all([ - fs.exists(paths.sentinelPath), + return yield* Effect.gen(function* () { + const installStep = "installing the pinned t3 runtime (this can take a few minutes)"; + yield* runner + .run({ + command: "npm", + args: ["install", "--prefix", stagingDir, "--no-fund", "--no-audit", `t3@${input.version}`], + // Native dependencies may compile from source on slower machines. + timeout: PINNED_RUNTIME_INSTALL_TIMEOUT, + }) + .pipe( + Effect.mapError((cause) => new PinnedRuntimeInstallError({ step: installStep, cause })), + Effect.filterOrFail( + (result) => result.code === 0, + (result) => + new PinnedRuntimeInstallError({ + step: installStep, + exitCode: Number(result.code), + stdoutLength: result.stdout.length, + stderrLength: result.stderr.length, + }), + ), + ); + + yield* input.validate(stagingPaths); + yield* fs + .writeFileString(stagingPaths.sentinelPath, `${input.version}\n`) + .pipe( + Effect.mapError( + (cause) => + new PinnedRuntimeInstallError({ step: "recording the completed install", cause }), + ), + ); + const published = yield* fs.rename(stagingDir, paths.versionDir).pipe( + Effect.as(true), + Effect.catch((cause) => + Effect.all([ fs.exists(paths.entryPath), + fs.readFileString(paths.sentinelPath).pipe(Effect.option), ]).pipe( - Effect.map(([sentinelExists, entryExists]) => sentinelExists && entryExists), - Effect.mapError( - (cause) => - new PinnedRuntimeInstallError({ step: "checking the pinned runtime", cause }), - ), - ); - if (alreadyPinned) { - return paths; - } - - yield* fs.remove(paths.versionDir, { recursive: true, force: true }).pipe( - Effect.andThen(fs.makeDirectory(paths.versionDir, { recursive: true })), Effect.mapError( - (cause) => + (checkCause) => new PinnedRuntimeInstallError({ - step: "preparing the pinned runtime directory", - cause, + step: "checking a concurrently published pinned runtime", + cause: checkCause, }), ), - ); - - const installStep = "installing the pinned t3 runtime (this can take a few minutes)"; - yield* runner - .run({ - command: "npm", - args: [ - "install", - "--prefix", - paths.versionDir, - "--no-fund", - "--no-audit", - `t3@${input.version}`, - ], - // Native deps (node-pty) can compile from source on slow boxes; the - // ProcessRunner default of 60s would kill a healthy install. - timeout: PINNED_RUNTIME_INSTALL_TIMEOUT, - }) - .pipe( - Effect.mapError((cause) => new PinnedRuntimeInstallError({ step: installStep, cause })), - Effect.filterOrFail( - (result) => result.code === 0, - (result) => - new PinnedRuntimeInstallError({ - step: installStep, - exitCode: Number(result.code), - stdoutLength: result.stdout.length, - stderrLength: result.stderr.length, - }), - ), - Effect.tapError(() => - fs.remove(paths.versionDir, { recursive: true, force: true }).pipe(Effect.ignore), - ), - ); - - yield* fs - .writeFileString(paths.sentinelPath, `${input.version}\n`) - .pipe( - Effect.mapError( - (cause) => - new PinnedRuntimeInstallError({ step: "recording the completed install", cause }), - ), - ); - - return paths; - }), - ); - }, -); - -/** Removes one pinned runtime while holding the same process-wide lock used - * by install/check/sentinel work, so cleanup cannot race another caller that - * is materializing or reusing the runtime tree. */ -export const removePinnedRuntimeInstallation = Effect.fn("cloud.pinned_runtime.remove")( - function* (input: { - readonly baseDir: string; - readonly version: string; - readonly fs: FileSystem.FileSystem; - readonly path: Path.Path; - }) { - const paths = pinnedRuntimePaths(input.path, input.baseDir, input.version); - yield* pinnedRuntimeInstallLock.withPermit( - input.fs - .remove(paths.versionDir, { recursive: true, force: true }) - .pipe( - Effect.mapError( - (cause) => - new PinnedRuntimeInstallError({ step: "removing the pinned runtime", cause }), + Effect.flatMap(([publishedEntryExists, publishedSentinel]) => + publishedEntryExists && + Option.isSome(publishedSentinel) && + publishedSentinel.value.trim() === input.version + ? Effect.succeed(false) + : Effect.fail( + new PinnedRuntimeInstallError({ + step: "publishing the pinned runtime", + cause, + }), + ), ), ), + ), ); - }, -); + if (!published) yield* input.validate(paths); + return paths; + }).pipe( + Effect.ensuring(fs.remove(stagingDir, { recursive: true, force: true }).pipe(Effect.ignore)), + ); +}); + +export const ensurePinnedRuntimeInstalled = (input: PinnedRuntimeInstallInput) => + pinnedRuntimeInstallLock.withPermit(installPinnedRuntime(input)); diff --git a/apps/server/src/cloud/selfUpdate.test.ts b/apps/server/src/cloud/selfUpdate.test.ts index bfac916a5..6fe1d5a4a 100644 --- a/apps/server/src/cloud/selfUpdate.test.ts +++ b/apps/server/src/cloud/selfUpdate.test.ts @@ -1,632 +1,144 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; -import { assert, it } from "@effect/vitest"; -import * as Duration from "effect/Duration"; +import { expect, it } from "@effect/vitest"; +import { HostProcessExecutablePath } from "@t3tools/shared/hostProcess"; +import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; -import * as Layer from "effect/Layer"; +import * as Fiber from "effect/Fiber"; import * as Path from "effect/Path"; -import * as TestClock from "effect/testing/TestClock"; import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; -import { - HostProcessArguments, - HostProcessEnvironment, - HostProcessExecutablePath, - HostProcessPlatform, -} from "@t3tools/shared/hostProcess"; - import * as ServerConfig from "../config.ts"; import * as ProcessRunner from "../processRunner.ts"; -import { - BOOT_SERVICE_UNIT_ENV, - BOOT_SERVICE_UNIT_FILE, - renderBootServiceUnit, -} from "./bootService.ts"; -import * as SelfUpdate from "./selfUpdate.ts"; - -const NODE_PATH = "/usr/local/bin/node"; +import * as ServiceLauncherClient from "./serviceLauncherClient.ts"; +import * as ServerSelfUpdate from "./selfUpdate.ts"; + +interface HarnessOptions { + readonly mode?: "web" | "desktop"; + readonly managed?: boolean; + readonly preflight?: "ready" | "blocked"; + readonly requestUpdate?: ServiceLauncherClient.ServiceLauncherClient["Service"]["requestUpdate"]; +} -const eventuallyFileString = Effect.fn("test.eventuallyFileString")(function* ( - filePath: string, - expected: string, +const makeHarness = Effect.fn("test.make_self_update_harness")(function* ( + options: HarnessOptions = {}, ) { const fs = yield* FileSystem.FileSystem; - for (let iteration = 0; iteration < 1_000; iteration += 1) { - const contents = yield* fs.readFileString(filePath); - if (contents === expected) { - return; - } - // The rollback performs real filesystem I/O on a detached fiber, which - // advancing TestClock does not await. - yield* Effect.yieldNow; - } - return yield* Effect.die(new Error(`Expected file contents were not observed at ${filePath}.`)); -}); - -const eventuallyTrue = Effect.fn("test.eventuallyTrue")(function* (predicate: () => boolean) { - for (let iteration = 0; iteration < 1_000; iteration += 1) { - if (predicate()) { - return; - } - yield* Effect.yieldNow; - } - return yield* Effect.die(new Error("Expected condition was not observed.")); -}); - -interface RecordedCommand { - readonly command: string; - readonly args: ReadonlyArray; -} - -const makeRecordingRunnerLayer = ( - commands: Array, - options?: { - readonly failWhen?: ((command: string, args: ReadonlyArray) => boolean) | undefined; - readonly stdoutFor?: - | ((command: string, args: ReadonlyArray) => string | undefined) - | undefined; - }, -) => - Layer.succeed( - ProcessRunner.ProcessRunner, - ProcessRunner.ProcessRunner.of({ - run: (input) => - Effect.sync(() => { - commands.push({ command: input.command, args: input.args }); - const failed = options?.failWhen?.(input.command, input.args) === true; - const versionFromPath = - input.command === NODE_PATH && input.args[1] === "--version" - ? /[/\\]runtime[/\\]versions[/\\]([^/\\]+)/.exec(input.args[0] ?? "")?.[1] - : undefined; + const path = yield* Path.Path; + const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-self-update-test-" }); + const order: string[] = []; + const runner = ProcessRunner.ProcessRunner.of({ + run: (input) => + Effect.gen(function* () { + if (input.command === "npm") { + order.push("install"); + const prefix = input.args[input.args.indexOf("--prefix") + 1]; + if (prefix === undefined) return yield* Effect.die("missing npm prefix"); + const entry = path.join(prefix, "node_modules", "t3", "dist", "bin.mjs"); + yield* fs.makeDirectory(path.dirname(entry), { recursive: true }).pipe(Effect.orDie); + yield* fs.writeFileString(entry, "export {};\n").pipe(Effect.orDie); return { - stdout: - options?.stdoutFor?.(input.command, input.args) ?? - (versionFromPath === undefined ? "" : `t3 v${versionFromPath}\n`), - stderr: failed ? `${input.command} exploded` : "", - code: ChildProcessSpawner.ExitCode(failed ? 1 : 0), + stdout: "", + stderr: "", + code: ChildProcessSpawner.ExitCode(0), timedOut: false, stdoutTruncated: false, stderrTruncated: false, }; - }), - }), - ); - -const provideHostRefs = (input: { - readonly platform: NodeJS.Platform; - readonly env: NodeJS.ProcessEnv; - readonly entryPath: string; -}) => - Effect.provide( - Layer.mergeAll( - Layer.succeed(HostProcessPlatform, input.platform), - Layer.succeed(HostProcessEnvironment, input.env), - Layer.succeed(HostProcessExecutablePath, NODE_PATH), - Layer.succeed(HostProcessArguments, [NODE_PATH, input.entryPath, "serve"]), - ), - ); - -it("recognizes published npm artifacts as swappable entry points", () => { - assert.isTrue(SelfUpdate.isPublishedCliEntry("/usr/local/lib/node_modules/t3/dist/bin.mjs")); - assert.isTrue( - SelfUpdate.isPublishedCliEntry("/home/theo/.npm/_npx/abc123/node_modules/t3/dist/bin.mjs"), - ); - assert.isTrue( - SelfUpdate.isPublishedCliEntry( - "C:\\Users\\theo\\AppData\\Roaming\\npm\\node_modules\\t3\\dist\\bin.mjs", - ), - ); - // Dev checkouts and the desktop bundle run apps/server/dist directly. - assert.isFalse(SelfUpdate.isPublishedCliEntry("/home/theo/dev/t3/apps/server/dist/bin.mjs")); - assert.isFalse(SelfUpdate.isPublishedCliEntry("")); -}); - -it.layer(NodeServices.layer)("resolveServerSelfUpdateCapability", (it) => { - const makeHome = Effect.fn("test.makeHome")(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const home = yield* fs.makeTempDirectoryScoped({ prefix: "t3-self-update-test-" }); - return { fs, path, home }; - }); - - const writeUnitReferencing = Effect.fn("test.writeUnitReferencing")(function* ( - home: string, - entryPath: string, - ) { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const unitDir = path.join(home, ".config", "systemd", "user"); - yield* fs.makeDirectory(unitDir, { recursive: true }); - yield* fs.writeFileString( - path.join(unitDir, "t3code.service"), - renderBootServiceUnit({ - nodePath: NODE_PATH, - t3EntryPath: entryPath, - baseDir: path.join(home, ".t3"), - logPath: path.join(home, ".t3", "userdata", "logs", "boot-service.log"), - unitPath: path.join(unitDir, "t3code.service"), + } + order.push("preflight"); + const result = + options.preflight === "blocked" + ? { status: "blocked", version: "1.1.0", reason: "local update required" } + : { status: "ready", version: "1.1.0", launcherProtocol: 1 }; + return { + // @effect-diagnostics-next-line preferSchemaOverJson:off - fake child-process stdout. + stdout: JSON.stringify(result), + stderr: "", + code: ChildProcessSpawner.ExitCode(0), + timedOut: false, + stdoutTruncated: false, + stderrTruncated: false, + }; }), - ); }); - - it.effect("reports boot-service for the systemd-spawned unit process", () => - Effect.gen(function* () { - const { home, path } = yield* makeHome(); - const entryPath = path.join(home, ".t3/runtime/versions/0.0.28/node_modules/t3/dist/bin.mjs"); - yield* writeUnitReferencing(home, entryPath); - const method = yield* SelfUpdate.resolveServerSelfUpdateCapability({ - desktopManaged: false, - }).pipe( - provideHostRefs({ - platform: "linux", - env: { - HOME: home, - INVOCATION_ID: "abc123", - [BOOT_SERVICE_UNIT_ENV]: BOOT_SERVICE_UNIT_FILE, - }, - entryPath, - }), - ); - assert.equal(method, "boot-service"); - }), - ); - - it.effect("does not claim a systemd process owned by another unit", () => - Effect.gen(function* () { - const { home, path } = yield* makeHome(); - const entryPath = path.join(home, ".t3/runtime/versions/0.0.28/node_modules/t3/dist/bin.mjs"); - yield* writeUnitReferencing(home, entryPath); - const method = yield* SelfUpdate.resolveServerSelfUpdateCapability({ - desktopManaged: false, - }).pipe( - provideHostRefs({ - platform: "linux", - env: { HOME: home, INVOCATION_ID: "abc123" }, - entryPath, - }), - ); - assert.isNull(method); - }), - ); - - it.effect("reports respawn for a manual run of the pinned artifact", () => - Effect.gen(function* () { - const { home, path } = yield* makeHome(); - const entryPath = path.join(home, ".t3/runtime/versions/0.0.28/node_modules/t3/dist/bin.mjs"); - yield* writeUnitReferencing(home, entryPath); - // Same unit on disk, but no INVOCATION_ID: restarting the unit would - // not replace this process, so it must respawn itself instead. - const method = yield* SelfUpdate.resolveServerSelfUpdateCapability({ - desktopManaged: false, - }).pipe(provideHostRefs({ platform: "linux", env: { HOME: home }, entryPath })); - assert.equal(method, "respawn"); - }), - ); - - it.effect("reports respawn for a foreground npx artifact on darwin", () => - Effect.gen(function* () { - const { home } = yield* makeHome(); - const method = yield* SelfUpdate.resolveServerSelfUpdateCapability({ - desktopManaged: false, - }).pipe( - provideHostRefs({ - platform: "darwin", - env: { HOME: home }, - entryPath: `${home}/.npm/_npx/abc123/node_modules/t3/dist/bin.mjs`, - }), - ); - assert.equal(method, "respawn"); - }), - ); - - it.effect("reports desktop-managed for desktop-supervised backends", () => - Effect.gen(function* () { - const { home, path } = yield* makeHome(); - // Desktop ownership wins over every process-shape heuristic: even a - // systemd-looking pinned artifact belongs to the app that spawned it. - const entryPath = path.join(home, ".t3/runtime/versions/0.0.28/node_modules/t3/dist/bin.mjs"); - yield* writeUnitReferencing(home, entryPath); - const method = yield* SelfUpdate.resolveServerSelfUpdateCapability({ - desktopManaged: true, - }).pipe( - provideHostRefs({ - platform: "linux", - env: { - HOME: home, - INVOCATION_ID: "abc123", - [BOOT_SERVICE_UNIT_ENV]: BOOT_SERVICE_UNIT_FILE, - }, - entryPath, - }), - ); - assert.equal(method, "desktop-managed"); - }), - ); - - it.effect("reports no method for dev checkouts and Windows", () => - Effect.gen(function* () { - const { home } = yield* makeHome(); - const devMethod = yield* SelfUpdate.resolveServerSelfUpdateCapability({ - desktopManaged: false, - }).pipe( - provideHostRefs({ - platform: "darwin", - env: { HOME: home }, - entryPath: `${home}/dev/t3/apps/server/dist/bin.mjs`, - }), - ); - assert.isNull(devMethod); - const windowsMethod = yield* SelfUpdate.resolveServerSelfUpdateCapability({ - desktopManaged: false, - }).pipe( - provideHostRefs({ - platform: "win32", - env: { HOME: home }, - entryPath: "C:\\Users\\theo\\AppData\\Roaming\\npm\\node_modules\\t3\\dist\\bin.mjs", - }), - ); - assert.isNull(windowsMethod); - }), - ); -}); - -it.layer(NodeServices.layer)("ServerSelfUpdate.update", (it) => { - interface RecordedSpawn { - readonly command: string; - readonly args: ReadonlyArray; - } - - const makeContext = Effect.fn("test.makeContext")(function* (options?: { - readonly platform?: NodeJS.Platform; - readonly bootService?: boolean; - readonly desktopManaged?: boolean; - readonly entryPath?: string; - readonly failWhen?: (command: string, args: ReadonlyArray) => boolean; - readonly stdoutFor?: (command: string, args: ReadonlyArray) => string | undefined; - readonly failSpawn?: boolean; - }) { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const home = yield* fs.makeTempDirectoryScoped({ prefix: "t3-self-update-test-" }); - const baseDir = path.join(home, ".t3"); - const entryPath = - options?.entryPath ?? - path.join(home, ".t3/runtime/versions/0.0.28/node_modules/t3/dist/bin.mjs"); - const env: NodeJS.ProcessEnv = - options?.bootService === true - ? { - HOME: home, - INVOCATION_ID: "abc123", - [BOOT_SERVICE_UNIT_ENV]: BOOT_SERVICE_UNIT_FILE, - } - : { HOME: home }; - if (options?.bootService === true) { - const unitDir = path.join(home, ".config", "systemd", "user"); - yield* fs.makeDirectory(unitDir, { recursive: true }); - yield* fs.writeFileString( - path.join(unitDir, "t3code.service"), - renderBootServiceUnit({ - nodePath: NODE_PATH, - t3EntryPath: entryPath, - baseDir, - logPath: path.join(baseDir, "userdata", "logs", "boot-service.log"), - unitPath: path.join(unitDir, "t3code.service"), - }), - ); - } - - const commands: Array = []; - const spawns: Array = []; - let exited = 0; - // layerTest always reports mode "web"; desktop-managed contexts overlay - // the mode the desktop app's bootstrap envelope would set. - const configLayer = - options?.desktopManaged === true - ? Layer.effect( - ServerConfig.ServerConfig, - Effect.gen(function* () { - const config = yield* ServerConfig.ServerConfig; - return { ...config, mode: "desktop" as const }; - }), - ).pipe(Layer.provide(ServerConfig.layerTest(home, baseDir))) - : ServerConfig.layerTest(home, baseDir); - const service = yield* SelfUpdate.make({ - host: { - spawnDetached: (command, args) => - Effect.sync(() => spawns.push({ command, args })).pipe( - Effect.andThen( - options?.failSpawn === true - ? Effect.fail( - new ProcessRunner.ProcessSpawnError({ - command, - argumentCount: args.length, - cause: new Error("detached spawn failed"), - }), - ) - : Effect.void, - ), - ), - exitProcess: () => { - exited += 1; - }, - }, - }).pipe( - Effect.provide( - Layer.mergeAll( - makeRecordingRunnerLayer(commands, { - failWhen: options?.failWhen, - stdoutFor: options?.stdoutFor, - }), - configLayer, - ), - ), - provideHostRefs({ platform: options?.platform ?? "linux", env, entryPath }), - ); - return { - fs, - path, - home, - baseDir, - entryPath, - commands, - spawns, - exitCount: () => exited, - service, - }; + const launcher = ServiceLauncherClient.ServiceLauncherClient.of({ + managed: options.managed ?? true, + trial: false, + requestUpdate: + options.requestUpdate ?? + (() => + Effect.sync(() => { + order.push("accept"); + return "launcher-id"; + })), + prepareTrial: Effect.sync((): undefined => undefined), }); - - it.effect("rejects dist-tags and other non-exact versions", () => - Effect.gen(function* () { - const context = yield* makeContext(); - const error = yield* context.service.update({ targetVersion: "latest" }).pipe(Effect.flip); - assert.include(error.reason, "not an exact t3 version"); - assert.lengthOf(context.commands, 0); - }), - ); - - it.effect("refuses to update a desktop-managed backend and points at the app", () => - Effect.gen(function* () { - const context = yield* makeContext({ desktopManaged: true, bootService: true }); - const error = yield* context.service.update({ targetVersion: "0.0.29" }).pipe(Effect.flip); - assert.include(error.reason, "desktop app"); - assert.lengthOf(context.commands, 0); - assert.lengthOf(context.spawns, 0); - }), - ); - - it.effect("fails without touching anything when no update method applies", () => - Effect.gen(function* () { - const context = yield* makeContext({ - entryPath: "/home/theo/dev/t3/apps/server/dist/bin.mjs", - }); - const error = yield* context.service.update({ targetVersion: "0.0.29" }).pipe(Effect.flip); - assert.include(error.reason, "cannot update itself"); - assert.lengthOf(context.commands, 0); - }), - ); - - it.effect("surfaces a failed npm install and never schedules a restart", () => - Effect.gen(function* () { - const context = yield* makeContext({ failWhen: (command) => command === "npm" }); - const error = yield* context.service.update({ targetVersion: "0.0.29" }).pipe(Effect.flip); - assert.equal(error.reason, "Could not install the requested t3 version."); - yield* TestClock.adjust(Duration.seconds(10)); - assert.lengthOf(context.spawns, 0); - assert.equal(context.exitCount(), 0); - }).pipe(Effect.provide(TestClock.layer())), + const config = yield* ServerConfig.ServerConfig.pipe( + Effect.provide(ServerConfig.layerTest(process.cwd(), baseDir)), ); - - it.effect("reinstalls the same version after a failed preflight", () => - Effect.gen(function* () { - let preflightAttempts = 0; - const context = yield* makeContext({ - failWhen: (command) => { - if (command !== NODE_PATH) return false; - preflightAttempts += 1; - return preflightAttempts === 1; - }, - }); - const versionDir = context.path.join(context.baseDir, "runtime", "versions", "0.0.29"); - const entryPath = context.path.join(versionDir, "node_modules", "t3", "dist", "bin.mjs"); - yield* context.fs.makeDirectory(context.path.dirname(entryPath), { recursive: true }); - yield* context.fs.writeFileString(entryPath, "export {};\n"); - yield* context.fs.writeFileString( - context.path.join(versionDir, ".install-complete"), - "0.0.29\n", - ); - - const firstError = yield* context.service - .update({ targetVersion: "0.0.29" }) - .pipe(Effect.flip); - assert.include(firstError.reason, "failed its version check"); - assert.isFalse(yield* context.fs.exists(versionDir)); - - const result = yield* context.service.update({ targetVersion: "0.0.29" }); - assert.deepEqual(result, { targetVersion: "0.0.29", method: "respawn" }); - assert.deepEqual( - context.commands.map((entry) => entry.command), - [NODE_PATH, "npm", NODE_PATH], - ); - }).pipe(Effect.provide(TestClock.layer())), + const selfUpdate = yield* ServerSelfUpdate.make().pipe( + Effect.provideService(ProcessRunner.ProcessRunner, runner), + Effect.provideService(ServiceLauncherClient.ServiceLauncherClient, launcher), + Effect.provideService(HostProcessExecutablePath, "/usr/bin/node"), + Effect.provide(ServerConfig.layer({ ...config, mode: options.mode ?? "web" })), ); + return { selfUpdate, order }; +}); - it.effect("rejects and removes an installed runtime that reports the wrong version", () => +it.layer(NodeServices.layer)("server self update", (it) => { + it.effect("stages and preflights before asking the launcher for an update ID", () => Effect.gen(function* () { - const context = yield* makeContext({ - stdoutFor: (command, args) => - command === NODE_PATH && args[1] === "--version" ? "t3 v0.0.28\n" : undefined, + const { selfUpdate, order } = yield* makeHarness(); + expect(yield* selfUpdate.update({ targetVersion: "1.1.0" })).toEqual({ + targetVersion: "1.1.0", + method: "boot-service", + updateId: "launcher-id", }); - const versionDir = context.path.join(context.baseDir, "runtime", "versions", "0.0.29"); - - const error = yield* context.service.update({ targetVersion: "0.0.29" }).pipe(Effect.flip); - - assert.include(error.reason, "did not report the requested"); - assert.isFalse(yield* context.fs.exists(versionDir)); - assert.lengthOf(context.spawns, 0); + expect(order).toEqual(["install", "preflight", "accept"]); }), ); - it.effect("reports a detached replacement spawn failure and leaves updates retryable", () => + it.effect("rejects invalid versions and desktop-managed servers before staging", () => Effect.gen(function* () { - const context = yield* makeContext({ failSpawn: true }); - - const first = yield* context.service.update({ targetVersion: "0.0.29" }).pipe(Effect.flip); - assert.include(first.reason, "Could not start the replacement"); - - const second = yield* context.service.update({ targetVersion: "0.0.29" }).pipe(Effect.flip); - assert.include(second.reason, "Could not start the replacement"); - assert.notInclude(second.reason, "already in progress"); - assert.lengthOf(context.spawns, 2); - assert.equal(context.exitCount(), 0); + const web = yield* makeHarness(); + expect( + (yield* web.selfUpdate.update({ targetVersion: "latest" }).pipe(Effect.flip)).reason, + ).toBe("'latest' is not an exact t3 version."); + const desktop = yield* makeHarness({ mode: "desktop" }); + expect( + (yield* desktop.selfUpdate.update({ targetVersion: "1.1.0" }).pipe(Effect.flip)).reason, + ).toContain("desktop app"); + expect([...web.order, ...desktop.order]).toEqual([]); }), ); - it.effect("installs, preflights, and respawns a foreground server", () => + it.effect("preserves the preflight refusal reason", () => Effect.gen(function* () { - const context = yield* makeContext(); - const progress: Array = []; - const result = yield* context.service.update({ targetVersion: "0.0.29" }, (stage) => - Effect.sync(() => progress.push(stage)), + const { selfUpdate } = yield* makeHarness({ preflight: "blocked" }); + expect((yield* selfUpdate.update({ targetVersion: "1.1.0" }).pipe(Effect.flip)).reason).toBe( + "local update required", ); - assert.deepEqual(result, { targetVersion: "0.0.29", method: "respawn" }); - assert.deepEqual(progress, ["downloading", "installing"]); - assert.lengthOf(context.spawns, 1); - - const concurrentError = yield* context.service - .update({ targetVersion: "0.0.30" }) - .pipe(Effect.flip); - assert.include(concurrentError.reason, "already in progress"); - - const pinnedEntry = context.path.join( - context.baseDir, - "runtime/versions/0.0.29/node_modules/t3/dist/bin.mjs", - ); - assert.deepEqual( - context.commands.map((entry) => [entry.command, ...entry.args].join(" ")), - [ - `npm install --prefix ${context.path.join(context.baseDir, "runtime/versions/0.0.29")} --no-fund --no-audit t3@0.0.29`, - `${NODE_PATH} ${pinnedEntry} --version`, - ], - ); - - // The restart is deferred so the RPC acknowledgement flushes first. - yield* TestClock.adjust(Duration.seconds(10)); - assert.lengthOf(context.spawns, 1); - const spawn = context.spawns[0]; - assert.equal(spawn?.command, "/bin/sh"); - assert.include(spawn?.args ?? [], pinnedEntry); - // The replacement replays the original CLI arguments. - assert.include(spawn?.args ?? [], "serve"); - assert.equal(context.exitCount(), 1); - }).pipe(Effect.provide(TestClock.layer())), - ); - - it.effect("rewrites the systemd unit and restarts the boot service", () => - Effect.gen(function* () { - const context = yield* makeContext({ bootService: true }); - const result = yield* context.service.update({ targetVersion: "0.0.29" }); - assert.deepEqual(result, { targetVersion: "0.0.29", method: "boot-service" }); - - const pinnedEntry = context.path.join( - context.baseDir, - "runtime/versions/0.0.29/node_modules/t3/dist/bin.mjs", - ); - const unit = yield* context.fs.readFileString( - context.path.join(context.home, ".config", "systemd", "user", "t3code.service"), - ); - assert.include(unit, `ExecStart=${NODE_PATH} ${pinnedEntry} serve`); - assert.deepEqual( - context.commands.map((entry) => entry.command), - ["npm", NODE_PATH, "systemctl"], - ); - assert.deepEqual(context.commands[2]?.args, ["--user", "daemon-reload"]); - - // Restart waits until after the update acknowledgement can flush. - yield* TestClock.adjust(Duration.seconds(10)); - assert.deepEqual(context.commands[3], { - command: "systemctl", - args: ["--user", "restart", "--no-block", "t3code.service"], - }); - assert.lengthOf(context.spawns, 0); - // systemd replaces the process; the server must not exit itself. - assert.equal(context.exitCount(), 0); - - // The queued restart returns while this process is still shutting - // down; the lock must stay held so a second update cannot rewrite the - // unit mid-teardown. - const concurrentError = yield* context.service - .update({ targetVersion: "0.0.30" }) - .pipe(Effect.flip); - assert.include(concurrentError.reason, "already in progress"); - }).pipe(Effect.provide(TestClock.layer())), + }), ); - it.effect("restores the previous unit and permits a retry when systemd restart fails", () => + it.effect("allows only one update at a time", () => Effect.gen(function* () { - let failRestart = true; - const context = yield* makeContext({ - bootService: true, - failWhen: (command, args) => { - if (command !== "systemctl" || args[1] !== "restart" || !failRestart) { - return false; - } - failRestart = false; - return true; - }, + const requested = yield* Deferred.make(); + const accepted = yield* Deferred.make(); + const { selfUpdate } = yield* makeHarness({ + requestUpdate: () => + Deferred.succeed(requested, undefined).pipe(Effect.andThen(Deferred.await(accepted))), }); - const unitPath = context.path.join( - context.home, - ".config", - "systemd", - "user", - BOOT_SERVICE_UNIT_FILE, - ); - const previousUnit = yield* context.fs.readFileString(unitPath); - - const first = yield* context.service.update({ targetVersion: "0.0.29" }); - assert.deepEqual(first, { targetVersion: "0.0.29", method: "boot-service" }); - yield* TestClock.adjust(Duration.seconds(10)); - yield* eventuallyFileString(unitPath, previousUnit); - yield* eventuallyTrue(() => context.commands.at(-1)?.args[1] === "daemon-reload"); - assert.deepEqual( - context.commands.slice(-2).map((entry) => entry.args), - [ - ["--user", "restart", "--no-block", BOOT_SERVICE_UNIT_FILE], - ["--user", "daemon-reload"], - ], - ); - - const retry = yield* context.service.update({ targetVersion: "0.0.30" }); - assert.deepEqual(retry, { targetVersion: "0.0.30", method: "boot-service" }); - }).pipe(Effect.provide(TestClock.layer())), - ); - - it.effect("restores the previous systemd unit when daemon-reload fails", () => - Effect.gen(function* () { - const context = yield* makeContext({ - bootService: true, - failWhen: (command) => command === "systemctl", + const first = yield* Effect.forkChild(selfUpdate.update({ targetVersion: "1.1.0" }), { + startImmediately: true, }); - const unitPath = context.path.join( - context.home, - ".config", - "systemd", - "user", - BOOT_SERVICE_UNIT_FILE, - ); - const previousUnit = yield* context.fs.readFileString(unitPath); - - const error = yield* context.service.update({ targetVersion: "0.0.29" }).pipe(Effect.flip); - assert.include(error.reason, "Reloading systemd units failed"); - assert.equal(yield* context.fs.readFileString(unitPath), previousUnit); - assert.deepEqual( - context.commands.map((entry) => entry.command), - ["npm", NODE_PATH, "systemctl", "systemctl"], + yield* Deferred.await(requested); + expect((yield* selfUpdate.update({ targetVersion: "1.1.1" }).pipe(Effect.flip)).reason).toBe( + "A server update is already in progress.", ); - - yield* TestClock.adjust(Duration.seconds(10)); - assert.lengthOf(context.spawns, 0); - assert.equal(context.exitCount(), 0); - }).pipe(Effect.provide(TestClock.layer())), + yield* Deferred.succeed(accepted, "launcher-id"); + expect((yield* Fiber.join(first)).updateId).toBe("launcher-id"); + }), ); }); diff --git a/apps/server/src/cloud/selfUpdate.ts b/apps/server/src/cloud/selfUpdate.ts index 9dcb713e1..58bb84117 100644 --- a/apps/server/src/cloud/selfUpdate.ts +++ b/apps/server/src/cloud/selfUpdate.ts @@ -1,7 +1,3 @@ -// @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, @@ -9,13 +5,7 @@ import { type ServerSelfUpdateProgressStage, type ServerSelfUpdateResult, } from "@t3tools/contracts"; -import { - HostProcessArguments, - HostProcessEnvironment, - HostProcessExecutablePath, - HostProcessPlatform, -} from "@t3tools/shared/hostProcess"; -import * as NodeChildProcess from "node:child_process"; +import { HostProcessExecutablePath } from "@t3tools/shared/hostProcess"; import * as Context from "effect/Context"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; @@ -25,208 +15,52 @@ 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. - */ + ensurePinnedRuntimeInstalled, + PinnedRuntimeInstallError, + PinnedRuntimePreflightBlockedError, +} from "./pinnedRuntime.ts"; +import { decodeServicePreflightResult } from "./servicePreflight.ts"; +import * as ServiceLauncherClient from "./serviceLauncherClient.ts"; +import { isExactServiceVersion, SERVICE_LAUNCHER_PROTOCOL } from "./serviceProtocol.ts"; 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; - /** Resolves once the foreground replacement process has actually spawned. */ - readonly spawnDetached: ( - command: string, - args: ReadonlyArray, - ) => Effect.Effect; - 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"). */ +export function resolveServerSelfUpdateCapability(input: { 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; -}); + readonly launcherManaged: boolean; +}): ServerSelfUpdateCapability | null { + if (input.desktopManaged) return "desktop-managed" as const; + return input.launcherManaged ? ("boot-service" as const) : null; +} export class ServerSelfUpdate extends Context.Service< ServerSelfUpdate, { readonly update: ( input: ServerSelfUpdateInput, - reportProgress?: (stage: ServerSelfUpdateProgressStage) => Effect.Effect, + reportProgress?: (stage: ServerSelfUpdateProgressStage) => Effect.Effect, ) => Effect.Effect; } >()("t3/cloud/selfUpdate/ServerSelfUpdate") {} -export const make = Effect.fn("cloud.server_self_update.make")(function* (options?: { - readonly host?: Partial; -}) { +export const make = Effect.fn("cloud.server_self_update.make")(function* () { const serverConfig = yield* ServerConfig.ServerConfig; + const launcher = yield* ServiceLauncherClient.ServiceLauncherClient; + const runner = yield* ProcessRunner.ProcessRunner; 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((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 execPath = yield* HostProcessExecutablePath; const inFlight = yield* Ref.make(false); + const capability: ServerSelfUpdateCapability | null = + serverConfig.mode === "desktop" ? "desktop-managed" : launcher.managed ? "boot-service" : null; 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) => - 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, reportProgress = () => Effect.void) { @@ -237,208 +71,123 @@ export const make = Effect.fn("cloud.server_self_update.make")(function* (option } if (capability === null) { return yield* failWith( - "This server cannot update itself; relaunch it manually with the new version.", + "Remote updates require the T3 Code background service. Run `t3 service install` on the server machine.", ); } - const activeMethod = capability; + const targetVersion = input.targetVersion.trim(); - if (!EXACT_VERSION_PATTERN.test(targetVersion)) { + if (!isExactServiceVersion(targetVersion)) { return yield* failWith(`'${targetVersion}' is not an exact t3 version.`); } - - const alreadyRunning = yield* Ref.getAndSet(inFlight, true); - if (alreadyRunning) { + if (yield* Ref.getAndSet(inFlight, true)) { return yield* failWith("A server update is already in progress."); } return yield* Effect.gen(function* () { yield* reportProgress("downloading"); - const runtimePaths = yield* ensurePinnedRuntimeInstalled({ + const paths = yield* ensurePinnedRuntimeInstalled({ baseDir: serverConfig.baseDir, version: targetVersion, fs, path, runner, + validate: (runtime) => + runner + .run({ + command: execPath, + args: [ + runtime.entryPath, + "__service-preflight", + "--database-path", + serverConfig.dbPath, + "--launcher-protocol", + String(SERVICE_LAUNCHER_PROTOCOL), + ], + timeout: PREFLIGHT_TIMEOUT, + }) + .pipe( + Effect.mapError( + (cause) => + new PinnedRuntimeInstallError({ + step: "running the staged service preflight", + cause, + }), + ), + Effect.flatMap( + ( + result, + ): Effect.Effect< + void, + PinnedRuntimeInstallError | PinnedRuntimePreflightBlockedError + > => { + if (result.code !== 0) { + return Effect.fail( + new PinnedRuntimeInstallError({ + step: "running the staged service preflight", + exitCode: Number(result.code), + stdoutLength: result.stdout.length, + stderrLength: result.stderr.length, + }), + ); + } + let parsed: unknown; + try { + parsed = JSON.parse(result.stdout.trim()); + } catch (cause) { + return Effect.fail( + new PinnedRuntimeInstallError({ + step: "decoding the staged service preflight", + cause, + }), + ); + } + const preflight = decodeServicePreflightResult(parsed); + if (preflight === undefined || preflight.version !== targetVersion) { + return Effect.fail( + new PinnedRuntimeInstallError({ + step: "verifying the staged service preflight", + }), + ); + } + return preflight.status === "ready" + ? Effect.void + : Effect.fail( + new PinnedRuntimePreflightBlockedError({ + version: targetVersion, + reason: preflight.reason, + }), + ); + }, + ), + ), }).pipe( - Effect.mapError((error) => failWith("Could not install the requested t3 version.", error)), + Effect.mapError((error) => + error._tag === "PinnedRuntimePreflightBlockedError" + ? failWith(error.reason, error) + : failWith(`Could not prepare t3@${targetVersion}.`, error), + ), ); yield* reportProgress("installing"); - // 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, - }) + const updateId = yield* launcher + .requestUpdate({ targetVersion }) .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, - }); - // Restart after the acknowledgement has had time to cross any relay - // hop. --no-block queues the restart job and exits before systemd - // stops this unit: a blocking restart's SIGTERM reaches the systemctl - // child (it shares this service's cgroup), which read as a restart - // failure and rolled the new unit back while the old server finished - // shutting down. With the handoff race gone, a non-zero exit or spawn - // error means systemd genuinely rejected the job while this process is - // still alive, so restoring the previous unit below stays correct. - yield* scheduleRestart( - Effect.gen(function* () { - const restart = yield* runner - .run({ - command: "systemctl", - args: ["--user", "restart", "--no-block", 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)), - ), - ), - Effect.catch((error) => - Effect.logError("Server self-update could not restart the boot service.").pipe( - Effect.annotateLogs({ targetVersion, error: error.reason }), - // Permit a retry only after the failed handoff was rolled - // back. A queued restart returns while this process is still - // shutting down; releasing the lock then would let a second - // update rewrite the unit mid-teardown. - Effect.andThen(Ref.set(inFlight, false)), - ), - ), - ), - ); - } 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)), - ), + failWith( + error._tag === "ServiceLauncherRejectedError" + ? error.reason + : "Could not ask the service launcher to activate the prepared update.", + error, ), ), ); - } - return { targetVersion, method: activeMethod }; + yield* Effect.logInfo("Server update prepared; handing off to the service launcher.", { + updateId, + targetVersion, + runtimePath: paths.entryPath, + }); + return { targetVersion, method: "boot-service" as const, updateId }; }).pipe(Effect.onError(() => Ref.set(inFlight, false))); }); diff --git a/apps/server/src/cloud/serviceLauncherClient.test.ts b/apps/server/src/cloud/serviceLauncherClient.test.ts new file mode 100644 index 000000000..6b9e926ff --- /dev/null +++ b/apps/server/src/cloud/serviceLauncherClient.test.ts @@ -0,0 +1,130 @@ +import { expect, it } from "@effect/vitest"; +import { HostProcessEnvironment } from "@t3tools/shared/hostProcess"; +import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; + +import { + SERVICE_LAUNCHER_CONTEXT_ENV, + type ServiceLauncherChildMessage, + type ServiceLauncherParentMessage, +} from "./serviceProtocol.ts"; +import * as ServiceLauncherClient from "./serviceLauncherClient.ts"; + +class FakeLauncherProcess { + readonly connected = true; + readonly env: Record; + readonly sent: ServiceLauncherChildMessage[] = []; + readonly #listeners = new Map) => void>>(); + + constructor(context: unknown) { + this.env = { [SERVICE_LAUNCHER_CONTEXT_ENV]: JSON.stringify(context) }; + } + + send = (message: ServiceLauncherChildMessage, callback?: (error: Error | null) => void) => { + this.sent.push(message); + callback?.(null); + return true; + }; + + on = (event: "message" | "disconnect", listener: (...args: ReadonlyArray) => void) => { + const listeners = this.#listeners.get(event) ?? new Set(); + listeners.add(listener); + this.#listeners.set(event, listeners); + }; + + off = (event: "message" | "disconnect", listener: (...args: ReadonlyArray) => void) => { + this.#listeners.get(event)?.delete(listener); + }; + + emit(message: ServiceLauncherParentMessage) { + for (const listener of this.#listeners.get("message") ?? []) listener(message); + } +} + +const makeClient = (host: FakeLauncherProcess, currentVersion: string) => + ServiceLauncherClient.make({ currentVersion }).pipe( + Effect.provideService(ServiceLauncherClient.ServiceLauncherHostProcess, host), + Effect.provideService(HostProcessEnvironment, host.env), + ); + +it.effect("waits for the launcher to durably commit the trial update ID", () => + Effect.gen(function* () { + const pending = { + id: "update-1", + fromVersion: "1.0.0", + targetVersion: "1.1.0", + status: "pending" as const, + }; + const host = new FakeLauncherProcess({ + protocol: 1, + childVersion: "1.1.0", + update: pending, + }); + const client = yield* makeClient(host, "1.1.0"); + const prepared = yield* Effect.forkChild(client.prepareTrial, { startImmediately: true }); + yield* Effect.yieldNow; + expect(host.sent).toEqual([{ type: "prepared", updateId: "update-1" }]); + + const committed = { + id: pending.id, + fromVersion: pending.fromVersion, + targetVersion: pending.targetVersion, + status: "committed" as const, + }; + host.emit({ type: "committed", updateId: committed.id }); + expect(yield* Fiber.join(prepared)).toEqual(committed); + }), +); + +it.effect("returns the launcher-generated ID only after update acceptance", () => + Effect.gen(function* () { + const host = new FakeLauncherProcess({ + protocol: 1, + childVersion: "1.0.0", + }); + const client = yield* makeClient(host, "1.0.0"); + const requested = yield* Effect.forkChild(client.requestUpdate({ targetVersion: "1.1.0" }), { + startImmediately: true, + }); + yield* Effect.yieldNow; + host.emit({ + type: "update-accepted", + updateId: "launcher-id", + }); + expect(yield* Fiber.join(requested)).toBe("launcher-id"); + }), +); + +it.effect("preserves a launcher rejection as a distinct error", () => + Effect.gen(function* () { + const host = new FakeLauncherProcess({ protocol: 1, childVersion: "1.0.0" }); + const client = yield* makeClient(host, "1.0.0"); + const requested = yield* Effect.forkChild(client.requestUpdate({ targetVersion: "1.1.0" }), { + startImmediately: true, + }); + yield* Effect.yieldNow; + host.emit({ type: "update-rejected", reason: "requires local update" }); + expect(yield* Fiber.join(requested).pipe(Effect.flip)).toMatchObject({ + _tag: "ServiceLauncherRejectedError", + targetVersion: "1.1.0", + reason: "requires local update", + }); + }), +); + +it.effect("rejects contradictory trial context instead of leaving activation closed", () => + Effect.gen(function* () { + const host = new FakeLauncherProcess({ + protocol: 1, + childVersion: "1.1.0", + update: { + id: "update-1", + fromVersion: "1.0.0", + targetVersion: "1.2.0", + status: "pending", + }, + }); + const error = yield* makeClient(host, "1.1.0").pipe(Effect.flip); + expect(error.message).toBe("The service launcher supplied invalid startup context."); + }), +); diff --git a/apps/server/src/cloud/serviceLauncherClient.ts b/apps/server/src/cloud/serviceLauncherClient.ts new file mode 100644 index 000000000..760642c29 --- /dev/null +++ b/apps/server/src/cloud/serviceLauncherClient.ts @@ -0,0 +1,249 @@ +import type { ServerSelfUpdateOutcome } from "@t3tools/contracts"; +import { HostProcessEnvironment } from "@t3tools/shared/hostProcess"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Schema from "effect/Schema"; + +import packageJson from "../../package.json" with { type: "json" }; +import { + decodeServiceLauncherContext, + decodeServiceLauncherParentMessage, + SERVICE_LAUNCHER_CONTEXT_ENV, + type ServiceLauncherChildMessage, + type ServiceLauncherParentMessage, +} from "./serviceProtocol.ts"; + +export class ServiceLauncherClientError extends Schema.TaggedErrorClass()( + "ServiceLauncherClientError", + { + operation: Schema.Literals([ + "decode-context", + "version-mismatch", + "ipc-unavailable", + "unmanaged", + "send", + "disconnect", + "timeout", + ]), + cause: Schema.optional(Schema.Defect()), + }, +) { + override get message(): string { + switch (this.operation) { + case "decode-context": + return "The service launcher supplied invalid startup context."; + case "version-mismatch": + return "The service launcher started a different t3 version."; + case "ipc-unavailable": + return "The service launcher IPC channel is unavailable."; + case "unmanaged": + return "This server is not managed by the launcher."; + case "send": + return "Could not send a request to the service launcher."; + case "disconnect": + return "The service launcher disconnected before acknowledging the request."; + case "timeout": + return "The service launcher did not respond within 30 seconds."; + } + } +} + +export class ServiceLauncherRejectedError extends Schema.TaggedErrorClass()( + "ServiceLauncherRejectedError", + { + targetVersion: Schema.String, + reason: Schema.String, + }, +) { + override get message(): string { + return this.reason; + } +} + +interface ServiceLauncherProcess { + readonly connected: boolean; + readonly send: ( + message: ServiceLauncherChildMessage, + callback?: (error: Error | null) => void, + ) => boolean; + readonly on: ( + event: "message" | "disconnect", + listener: (...args: ReadonlyArray) => void, + ) => void; + readonly off: ( + event: "message" | "disconnect", + listener: (...args: ReadonlyArray) => void, + ) => void; +} + +export const ServiceLauncherHostProcess = Context.Reference( + "t3/cloud/serviceLauncherHostProcess", + { + defaultValue: () => ({ + connected: process.connected && process.send !== undefined, + send: (message, callback) => { + if (process.send === undefined) return false; + return callback === undefined ? process.send(message) : process.send(message, callback); + }, + on: (event, listener) => { + process.on(event, listener); + }, + off: (event, listener) => { + process.off(event, listener); + }, + }), + }, +); + +export class ServiceLauncherClient extends Context.Service< + ServiceLauncherClient, + { + readonly managed: boolean; + readonly trial: boolean; + readonly requestUpdate: (input: { + readonly targetVersion: string; + }) => Effect.Effect; + readonly prepareTrial: Effect.Effect< + ServerSelfUpdateOutcome | undefined, + ServiceLauncherClientError + >; + } +>()("t3/cloud/serviceLauncherClient") {} + +const resolveStartup = Effect.fn("cloud.service_launcher_client.resolve_startup")( + function* (options?: { readonly currentVersion?: string }) { + const host = yield* ServiceLauncherHostProcess; + const environment = yield* HostProcessEnvironment; + const currentVersion = options?.currentVersion ?? packageJson.version; + const rawContext = environment[SERVICE_LAUNCHER_CONTEXT_ENV]; + const context = rawContext === undefined ? undefined : decodeServiceLauncherContext(rawContext); + + if (rawContext !== undefined && context === undefined) { + return yield* new ServiceLauncherClientError({ operation: "decode-context" }); + } + if (context !== undefined && context.childVersion !== currentVersion) { + return yield* new ServiceLauncherClientError({ operation: "version-mismatch" }); + } + + const managed = context !== undefined && host.connected; + if (context !== undefined && !managed) { + return yield* new ServiceLauncherClientError({ operation: "ipc-unavailable" }); + } + + return { host, context, managed }; + }, +); + +export const resolveServiceLauncherMode = Effect.fn("cloud.service_launcher_client.resolve_mode")( + function* () { + const { context, managed } = yield* resolveStartup(); + return { managed, trial: context?.update?.status === "pending" }; + }, +); + +export const make = Effect.fn("cloud.service_launcher_client.make")(function* (options?: { + readonly currentVersion?: string; +}) { + const { host, context, managed } = yield* resolveStartup(options); + + const exchange = ( + message: ServiceLauncherChildMessage, + accept: (reply: ServiceLauncherParentMessage) => boolean, + ) => + Effect.callback((resume) => { + if (!managed) { + resume(Effect.fail(new ServiceLauncherClientError({ operation: "unmanaged" }))); + return; + } + + let settled = false; + const cleanup = () => { + host.off("message", onMessage); + host.off("disconnect", onDisconnect); + }; + const settle = ( + effect: Effect.Effect, + ) => { + if (settled) return; + settled = true; + cleanup(); + resume(effect); + }; + const onMessage = (...args: ReadonlyArray) => { + const reply = decodeServiceLauncherParentMessage(args[0]); + if (reply !== undefined && accept(reply)) settle(Effect.succeed(reply)); + }; + const onDisconnect = () => + settle(Effect.fail(new ServiceLauncherClientError({ operation: "disconnect" }))); + + host.on("message", onMessage); + host.on("disconnect", onDisconnect); + try { + host.send(message, (error) => { + if (error !== null) { + settle( + Effect.fail(new ServiceLauncherClientError({ operation: "send", cause: error })), + ); + } + }); + } catch (cause) { + settle(Effect.fail(new ServiceLauncherClientError({ operation: "send", cause }))); + } + + return Effect.sync(cleanup); + }).pipe( + Effect.timeoutOrElse({ + duration: "30 seconds", + orElse: () => Effect.fail(new ServiceLauncherClientError({ operation: "timeout" })), + }), + ); + + const requestUpdate = (input: { readonly targetVersion: string }) => + exchange( + { type: "request-update", ...input }, + (reply) => reply.type === "update-accepted" || reply.type === "update-rejected", + ).pipe( + Effect.flatMap((reply) => + reply.type === "update-accepted" + ? Effect.succeed(reply.updateId) + : reply.type === "update-rejected" + ? Effect.fail( + new ServiceLauncherRejectedError({ + targetVersion: input.targetVersion, + reason: reply.reason, + }), + ) + : Effect.die("service launcher returned an impossible update response"), + ), + ); + + const pending = context?.update?.status === "pending" ? context.update : undefined; + const outcome = + context?.update === undefined || context.update.status === "pending" + ? undefined + : context.update; + const prepareTrial = + pending !== undefined + ? exchange( + { type: "prepared", updateId: pending.id }, + (reply) => reply.type === "committed" && reply.updateId === pending.id, + ).pipe( + Effect.flatMap((reply) => { + if (reply.type !== "committed") { + return Effect.die("service launcher returned an impossible prepared response"); + } + return Effect.succeed({ ...pending, status: "committed" as const }); + }), + ) + : Effect.succeed(outcome); + + return ServiceLauncherClient.of({ + managed, + trial: pending !== undefined, + requestUpdate, + prepareTrial, + }); +}); + +export const layer = Layer.effect(ServiceLauncherClient, make()); diff --git a/apps/server/src/cloud/servicePreflight.test.ts b/apps/server/src/cloud/servicePreflight.test.ts new file mode 100644 index 000000000..d2ce6db8d --- /dev/null +++ b/apps/server/src/cloud/servicePreflight.test.ts @@ -0,0 +1,47 @@ +// @effect-diagnostics nodeBuiltinImport:off +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; +import * as NodeSqlite from "node:sqlite"; + +import { migrationManifest } from "../persistence/Migrations.ts"; +import { runServicePreflight } from "./servicePreflight.ts"; + +it.layer(NodeServices.layer)("service update preflight", (it) => { + it.effect("requires exact migration-manifest equality without mutating the database", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-service-preflight-test-" }); + const databasePath = path.join(root, "state.sqlite"); + const database = new NodeSqlite.DatabaseSync(databasePath); + database.exec("CREATE TABLE effect_sql_migrations (migration_id INTEGER, name TEXT)"); + const insert = database.prepare( + "INSERT INTO effect_sql_migrations (migration_id, name) VALUES (?, ?)", + ); + for (const [id, name] of migrationManifest) insert.run(id, name); + database.close(); + + expect(runServicePreflight({ databasePath, launcherProtocol: 1, version: "1.2.3" })).toEqual({ + status: "ready", + version: "1.2.3", + launcherProtocol: 1, + }); + + const changed = new NodeSqlite.DatabaseSync(databasePath); + changed.exec("DELETE FROM effect_sql_migrations WHERE migration_id = 35"); + changed.close(); + const blocked = runServicePreflight({ + databasePath, + launcherProtocol: 1, + version: "1.2.3", + }); + expect(blocked.status).toBe("blocked"); + if (blocked.status === "blocked") { + expect(blocked.reason).toContain("npx t3@1.2.3 service update"); + } + }), + ); +}); diff --git a/apps/server/src/cloud/servicePreflight.ts b/apps/server/src/cloud/servicePreflight.ts new file mode 100644 index 000000000..1843e1638 --- /dev/null +++ b/apps/server/src/cloud/servicePreflight.ts @@ -0,0 +1,96 @@ +import * as NodeSqlite from "node:sqlite"; + +import packageJson from "../../package.json" with { type: "json" }; +import { migrationManifest } from "../persistence/Migrations.ts"; +import { SERVICE_LAUNCHER_PROTOCOL } from "./serviceProtocol.ts"; + +export type ServicePreflightResult = + | { + readonly status: "ready"; + readonly version: string; + readonly launcherProtocol: typeof SERVICE_LAUNCHER_PROTOCOL; + } + | { + readonly status: "blocked"; + readonly version: string; + readonly reason: string; + }; + +const localUpdateReason = (version: string) => + `This version includes a database update and cannot be installed remotely. Run \`npx t3@${version} service update\` on the server machine.`; + +const isMigrationRow = ( + value: unknown, +): value is { readonly migration_id: number; readonly name: string } => + typeof value === "object" && + value !== null && + "migration_id" in value && + typeof value.migration_id === "number" && + "name" in value && + typeof value.name === "string"; + +export function runServicePreflight(input: { + readonly databasePath: string; + readonly launcherProtocol: number; + readonly version?: string; +}): ServicePreflightResult { + const version = input.version ?? packageJson.version; + if (input.launcherProtocol !== SERVICE_LAUNCHER_PROTOCOL) { + return { + status: "blocked", + version, + reason: + "This release requires a newer T3 Code service launcher. Update it on the server machine.", + }; + } + + try { + const database = new NodeSqlite.DatabaseSync(input.databasePath, { readOnly: true }); + try { + const rows: ReadonlyArray = database + .prepare("SELECT migration_id, name FROM effect_sql_migrations ORDER BY migration_id") + .all(); + const exact = + rows.length === migrationManifest.length && + rows.every((row, index) => { + const expected = migrationManifest[index]; + return ( + isMigrationRow(row) && row.migration_id === expected?.[0] && row.name === expected?.[1] + ); + }); + if (!exact) return { status: "blocked", version, reason: localUpdateReason(version) }; + } finally { + database.close(); + } + } catch { + return { status: "blocked", version, reason: localUpdateReason(version) }; + } + + return { status: "ready", version, launcherProtocol: SERVICE_LAUNCHER_PROTOCOL }; +} + +export function decodeServicePreflightResult(value: unknown): ServicePreflightResult | undefined { + if (typeof value !== "object" || value === null) { + return undefined; + } + const record = value as Record; + if ( + record.status === "ready" && + record.launcherProtocol === SERVICE_LAUNCHER_PROTOCOL && + typeof record.version === "string" + ) { + return { + status: "ready", + version: record.version, + launcherProtocol: SERVICE_LAUNCHER_PROTOCOL, + }; + } + if ( + record.status === "blocked" && + typeof record.version === "string" && + typeof record.reason === "string" + ) { + return { status: "blocked", version: record.version, reason: record.reason }; + } + return undefined; +} diff --git a/apps/server/src/cloud/serviceProtocol.ts b/apps/server/src/cloud/serviceProtocol.ts new file mode 100644 index 000000000..921bc1447 --- /dev/null +++ b/apps/server/src/cloud/serviceProtocol.ts @@ -0,0 +1,226 @@ +import type { ServerSelfUpdateOutcome } from "@t3tools/contracts"; + +export const SERVICE_LAUNCHER_PROTOCOL = 1 as const; +export const SERVICE_LAUNCHER_CONTEXT_ENV = "T3_SERVICE_LAUNCHER_CONTEXT"; +export const SERVICE_LAUNCHER_FILE = "service-launcher.mjs"; +export const SERVICE_STATE_FILE = "service-state.json"; + +export interface PendingServiceUpdate { + readonly id: string; + readonly fromVersion: string; + readonly targetVersion: string; + readonly status: "pending"; +} + +export type ServiceUpdateRecord = PendingServiceUpdate | ServerSelfUpdateOutcome; + +export interface ServiceState { + readonly protocol: typeof SERVICE_LAUNCHER_PROTOCOL; + readonly activeVersion: string; + readonly update?: ServiceUpdateRecord; +} + +/** Context is copied from launcher-owned state when a child is spawned. */ +export interface ServiceLauncherContext { + readonly protocol: typeof SERVICE_LAUNCHER_PROTOCOL; + readonly childVersion: string; + readonly update?: ServiceUpdateRecord; +} + +export type ServiceLauncherChildMessage = + | { + readonly type: "request-update"; + readonly targetVersion: string; + } + | { + readonly type: "prepared"; + readonly updateId: string; + }; + +export type ServiceLauncherParentMessage = + | { + readonly type: "update-accepted"; + readonly updateId: string; + } + | { + readonly type: "update-rejected"; + readonly reason: string; + } + | { + readonly type: "committed"; + readonly updateId: string; + }; + +const SEMVER_NUMBER = "(?:0|[1-9]\\d*)"; +const SEMVER_PRERELEASE = `(?:${SEMVER_NUMBER}|[0-9]*[A-Za-z-][0-9A-Za-z-]*)`; +const EXACT_SERVICE_VERSION = new RegExp( + `^${SEMVER_NUMBER}\\.${SEMVER_NUMBER}\\.${SEMVER_NUMBER}(?:-${SEMVER_PRERELEASE}(?:\\.${SEMVER_PRERELEASE})*)?(?:\\+[0-9A-Za-z-]+(?:\\.[0-9A-Za-z-]+)*)?$`, +); + +/** Accepts exact SemVer only: never dist-tags or ranges passed to npm or filesystem paths. */ +export const isExactServiceVersion = (version: string): boolean => + EXACT_SERVICE_VERSION.test(version); + +const isRecord = (value: unknown): value is Record => + typeof value === "object" && value !== null && !Array.isArray(value); + +export function decodeServiceUpdate(value: unknown): ServiceUpdateRecord | undefined { + if (!isRecord(value)) return undefined; + const { id, fromVersion, targetVersion, status } = value; + if ( + typeof id !== "string" || + id.trim() === "" || + typeof fromVersion !== "string" || + !isExactServiceVersion(fromVersion) || + typeof targetVersion !== "string" || + !isExactServiceVersion(targetVersion) + ) { + return undefined; + } + if (status === "pending") { + return { id, fromVersion, targetVersion, status }; + } + if ( + (status === "committed" || status === "rolled-back" || status === "failed") && + (value.reason === undefined || (typeof value.reason === "string" && value.reason.trim() !== "")) + ) { + return { + id, + fromVersion, + targetVersion, + status, + ...(typeof value.reason === "string" ? { reason: value.reason } : {}), + }; + } + return undefined; +} + +/** SemVer precedence for exact versions. Build metadata is ignored. */ +export function compareExactServiceVersions(left: string, right: string): number { + const parse = (version: string) => { + const withoutBuild = version.split("+", 1)[0] ?? version; + const separator = withoutBuild.indexOf("-"); + const core = separator === -1 ? withoutBuild : withoutBuild.slice(0, separator); + const prerelease = separator === -1 ? undefined : withoutBuild.slice(separator + 1); + const [major = "0", minor = "0", patch = "0"] = core.split("."); + return { + core: [BigInt(major), BigInt(minor), BigInt(patch)] as const, + prerelease: prerelease?.split(".") ?? [], + }; + }; + const a = parse(left); + const b = parse(right); + for (let index = 0; index < 3; index += 1) { + const x = a.core[index] ?? 0n; + const y = b.core[index] ?? 0n; + if (x !== y) return x < y ? -1 : 1; + } + if (a.prerelease.length === 0 || b.prerelease.length === 0) { + return a.prerelease.length === b.prerelease.length ? 0 : a.prerelease.length === 0 ? 1 : -1; + } + const count = Math.max(a.prerelease.length, b.prerelease.length); + for (let index = 0; index < count; index += 1) { + const x = a.prerelease[index]; + const y = b.prerelease[index]; + if (x === undefined || y === undefined) return x === undefined ? -1 : 1; + if (x === y) continue; + const xNumeric = /^\d+$/.test(x); + const yNumeric = /^\d+$/.test(y); + if (xNumeric && yNumeric) return BigInt(x) < BigInt(y) ? -1 : 1; + if (xNumeric !== yNumeric) return xNumeric ? -1 : 1; + return x < y ? -1 : 1; + } + return 0; +} + +export function decodeServiceState(value: unknown): ServiceState | undefined { + if (!isRecord(value)) return undefined; + const update = value.update === undefined ? undefined : decodeServiceUpdate(value.update); + if ( + value.protocol !== SERVICE_LAUNCHER_PROTOCOL || + typeof value.activeVersion !== "string" || + !isExactServiceVersion(value.activeVersion) || + (value.update !== undefined && update === undefined) || + (update !== undefined && + compareExactServiceVersions(update.targetVersion, update.fromVersion) <= 0) || + (update?.status === "pending" && update.fromVersion !== value.activeVersion) || + (update?.status === "committed" && update.targetVersion !== value.activeVersion) || + ((update?.status === "rolled-back" || update?.status === "failed") && + update.fromVersion !== value.activeVersion) + ) { + return undefined; + } + return { + protocol: SERVICE_LAUNCHER_PROTOCOL, + activeVersion: value.activeVersion, + ...(update === undefined ? {} : { update }), + }; +} + +export function parseServiceState(value: string): ServiceState | undefined { + try { + return decodeServiceState(JSON.parse(value) as unknown); + } catch { + return undefined; + } +} + +export function decodeServiceLauncherContext(value: string): ServiceLauncherContext | undefined { + let parsed: unknown; + try { + parsed = JSON.parse(value) as unknown; + } catch { + return undefined; + } + if ( + !isRecord(parsed) || + parsed.protocol !== SERVICE_LAUNCHER_PROTOCOL || + typeof parsed.childVersion !== "string" || + !isExactServiceVersion(parsed.childVersion) + ) { + return undefined; + } + const update = parsed.update === undefined ? undefined : decodeServiceUpdate(parsed.update); + if (parsed.update !== undefined && update === undefined) return undefined; + const selectedVersion = + update?.status === "pending" || update?.status === "committed" + ? update.targetVersion + : update === undefined + ? parsed.childVersion + : update.fromVersion; + if (parsed.childVersion !== selectedVersion) { + return undefined; + } + return { + protocol: SERVICE_LAUNCHER_PROTOCOL, + childVersion: parsed.childVersion, + ...(update === undefined ? {} : { update }), + }; +} + +export function decodeServiceLauncherChildMessage( + value: unknown, +): ServiceLauncherChildMessage | undefined { + if (!isRecord(value)) return undefined; + if (value.type === "request-update" && typeof value.targetVersion === "string") { + return { type: value.type, targetVersion: value.targetVersion }; + } + return value.type === "prepared" && typeof value.updateId === "string" + ? { type: value.type, updateId: value.updateId } + : undefined; +} + +export function decodeServiceLauncherParentMessage( + value: unknown, +): ServiceLauncherParentMessage | undefined { + if (!isRecord(value)) return undefined; + if (value.type === "update-rejected" && typeof value.reason === "string") { + return { type: value.type, reason: value.reason }; + } + if (value.type === "update-accepted" && typeof value.updateId === "string") { + return { type: value.type, updateId: value.updateId }; + } + return value.type === "committed" && typeof value.updateId === "string" + ? { type: value.type, updateId: value.updateId } + : undefined; +} diff --git a/apps/server/src/environment/ServerEnvironment.ts b/apps/server/src/environment/ServerEnvironment.ts index c64415805..0ce29950b 100644 --- a/apps/server/src/environment/ServerEnvironment.ts +++ b/apps/server/src/environment/ServerEnvironment.ts @@ -10,6 +10,7 @@ import * as Schema from "effect/Schema"; import packageJson from "../../package.json" with { type: "json" }; import { resolveServerSelfUpdateCapability } from "../cloud/selfUpdate.ts"; +import { resolveServiceLauncherMode } from "../cloud/serviceLauncherClient.ts"; import * as ServerConfig from "../config.ts"; import * as ProcessRunner from "../processRunner.ts"; import { resolveServerEnvironmentLabel } from "./ServerEnvironmentLabel.ts"; @@ -125,8 +126,10 @@ export const make = Effect.gen(function* () { const environmentId = EnvironmentId.make(environmentIdRaw); const cwdBaseName = path.basename(serverConfig.cwd).trim(); const label = yield* resolveServerEnvironmentLabel({ cwdBaseName }); - const serverSelfUpdate = yield* resolveServerSelfUpdateCapability({ + const launcher = yield* resolveServiceLauncherMode(); + const serverSelfUpdate = resolveServerSelfUpdateCapability({ desktopManaged: serverConfig.mode === "desktop", + launcherManaged: launcher.managed, }); const descriptor: ExecutionEnvironmentDescriptor = { @@ -145,9 +148,7 @@ export const make = Effect.gen(function* () { threadDeltaSubscription: true, threadTitleRegeneration: true, ...(serverSelfUpdate === null ? {} : { serverSelfUpdate }), - ...(serverSelfUpdate === "boot-service" || serverSelfUpdate === "respawn" - ? { serverSelfUpdateProgress: true } - : {}), + ...(serverSelfUpdate === "boot-service" ? { serverSelfUpdateProgress: true } : {}), }, }; diff --git a/apps/server/src/orchestration/Layers/CheckpointReactor.ts b/apps/server/src/orchestration/Layers/CheckpointReactor.ts index 54cbe3eb5..95adee0cf 100644 --- a/apps/server/src/orchestration/Layers/CheckpointReactor.ts +++ b/apps/server/src/orchestration/Layers/CheckpointReactor.ts @@ -29,6 +29,7 @@ import { import * as CheckpointStore from "../../checkpointing/CheckpointStore.ts"; import { ProviderService } from "../../provider/Services/ProviderService.ts"; import { CheckpointReactor, type CheckpointReactorShape } from "../Services/CheckpointReactor.ts"; +import { forkParked } from "../../serverActivation.ts"; import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts"; import { ProjectionSnapshotQuery } from "../Services/ProjectionSnapshotQuery.ts"; import { RuntimeReceiptBus } from "../Services/RuntimeReceiptBus.ts"; @@ -912,7 +913,7 @@ const make = Effect.gen(function* () { const worker = yield* makeDrainableWorker(processInputSafely); const start: CheckpointReactorShape["start"] = Effect.fn("start")(function* () { - yield* Effect.forkScoped( + yield* forkParked( Stream.runForEach(orchestrationEngine.streamDomainEvents, (event) => { if ( event.type !== "thread.turn-start-requested" && @@ -926,7 +927,7 @@ const make = Effect.gen(function* () { }), ); - yield* Effect.forkScoped( + yield* forkParked( Stream.runForEach(providerService.streamEvents, (event) => { if (event.type !== "turn.started" && event.type !== "turn.completed") { return Effect.void; diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts index 8b4423c0b..933f1fc4e 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts @@ -39,6 +39,7 @@ import { ProviderCommandReactor, type ProviderCommandReactorShape, } from "../Services/ProviderCommandReactor.ts"; +import { forkParked, ServerActivation } from "../../serverActivation.ts"; import { resolveSourceControlWriterModelSelection, ServerSettingsService, @@ -913,19 +914,25 @@ const make = Effect.gen(function* () { ...(input.title !== undefined ? { title: input.title } : {}), }); }); - const clearInterruptedThreadTitleRegenerations = Effect.fn( - "clearInterruptedThreadTitleRegenerations", + const findInterruptedThreadTitleRegenerations = Effect.fn( + "findInterruptedThreadTitleRegenerations", )(function* () { const readModel = yield* projectionSnapshotQuery.getCommandReadModel(); + return readModel.threads.flatMap((thread) => { + const requestId = thread.titleRegeneration?.requestId; + return requestId === undefined ? [] : [{ threadId: thread.id, requestId }]; + }); + }); + const clearInterruptedThreadTitleRegenerations = Effect.fn( + "clearInterruptedThreadTitleRegenerations", + )(function* ( + interrupted: ReadonlyArray<{ readonly threadId: ThreadId; readonly requestId: CommandId }>, + ) { yield* Effect.forEach( - readModel.threads, - (thread) => { - const requestId = thread.titleRegeneration?.requestId; - if (requestId === undefined) { - return Effect.void; - } + interrupted, + ({ threadId, requestId }) => { return dispatchThreadTitleRegenerationCompletion({ - threadId: thread.id, + threadId, requestId, }).pipe( Effect.catchCause((cause) => { @@ -935,7 +942,7 @@ const make = Effect.gen(function* () { return Effect.logWarning( "provider command reactor failed to clear interrupted title regeneration", { - threadId: thread.id, + threadId, cause: Cause.pretty(cause), }, ); @@ -1370,7 +1377,7 @@ const make = Effect.gen(function* () { processDomainEvent(event).pipe( Effect.catchCause((cause) => { if (Cause.hasInterruptsOnly(cause)) { - return Effect.failCause(cause); + return Effect.interrupt; } return Effect.logWarning("provider command reactor failed to process event", { eventType: event.type, @@ -1382,6 +1389,17 @@ const make = Effect.gen(function* () { const worker = yield* makeDrainableWorker(processDomainEventSafely); const start: ProviderCommandReactorShape["start"] = Effect.fn("start")(function* () { + const interruptedTitleRegenerations = yield* findInterruptedThreadTitleRegenerations().pipe( + Effect.catchCause((cause) => { + if (Cause.hasInterruptsOnly(cause)) { + return Effect.interrupt; + } + return Effect.logWarning( + "provider command reactor failed to find interrupted title regenerations", + { cause: Cause.pretty(cause) }, + ).pipe(Effect.as([])); + }), + ); const processEvent = Effect.fn("processEvent")(function* (event: OrchestrationEvent) { if ( (event.type === "thread.meta-updated" && event.payload.regenerateTitle === true) || @@ -1397,14 +1415,14 @@ const make = Effect.gen(function* () { } }); - yield* Effect.forkScoped( - Stream.runForEach(orchestrationEngine.streamDomainEvents, processEvent), - ); + yield* forkParked(Stream.runForEach(orchestrationEngine.streamDomainEvents, processEvent)); // The domain event stream is hot, so work pending before this reactor // starts cannot be resumed. Correlated completions only clear the request // captured here, leaving any newer request untouched. - yield* clearInterruptedThreadTitleRegenerations().pipe( + const clearInterrupted = clearInterruptedThreadTitleRegenerations( + interruptedTitleRegenerations, + ).pipe( Effect.catchCause((cause) => { if (Cause.hasInterruptsOnly(cause)) { return Effect.interrupt; @@ -1417,6 +1435,12 @@ const make = Effect.gen(function* () { ); }), ); + const activation = yield* ServerActivation; + if (activation === undefined) { + yield* clearInterrupted; + } else { + yield* forkParked(clearInterrupted); + } }); return { diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts index ca89568fa..ccdf0524a 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts @@ -39,6 +39,7 @@ import { ProviderRuntimeIngestionService, type ProviderRuntimeIngestionShape, } from "../Services/ProviderRuntimeIngestion.ts"; +import { forkParked } from "../../serverActivation.ts"; import { ServerSettingsService } from "../../serverSettings.ts"; const providerTurnKey = (threadId: ThreadId, turnId: TurnId) => `${threadId}:${turnId}`; @@ -1979,12 +1980,12 @@ const make = Effect.gen(function* () { const start: ProviderRuntimeIngestionShape["start"] = () => Effect.gen(function* () { - yield* Effect.forkScoped( + yield* forkParked( Stream.runForEach(providerService.streamEvents, (event) => worker.enqueue({ source: "runtime", event }), ), ); - yield* Effect.forkScoped( + yield* forkParked( Stream.runForEach(orchestrationEngine.streamDomainEvents, (event) => { if (event.type !== "thread.turn-start-requested") { return Effect.void; diff --git a/apps/server/src/orchestration/Layers/ThreadDeletionReactor.ts b/apps/server/src/orchestration/Layers/ThreadDeletionReactor.ts index 7d8a24069..a026f5ad8 100644 --- a/apps/server/src/orchestration/Layers/ThreadDeletionReactor.ts +++ b/apps/server/src/orchestration/Layers/ThreadDeletionReactor.ts @@ -12,6 +12,7 @@ import { ThreadDeletionReactor, type ThreadDeletionReactorShape, } from "../Services/ThreadDeletionReactor.ts"; +import { forkParked } from "../../serverActivation.ts"; type ThreadDeletedEvent = Extract; @@ -80,7 +81,7 @@ const make = Effect.gen(function* () { const worker = yield* makeDrainableWorker(processThreadDeletedSafely); const start: ThreadDeletionReactorShape["start"] = Effect.fn("start")(function* () { - yield* Effect.forkScoped( + yield* forkParked( Stream.runForEach(orchestrationEngine.streamDomainEvents, (event) => { if (event.type !== "thread.deleted") { return Effect.void; diff --git a/apps/server/src/persistence/Layers/Sqlite.ts b/apps/server/src/persistence/Layers/Sqlite.ts index 72c4938a3..888d6a068 100644 --- a/apps/server/src/persistence/Layers/Sqlite.ts +++ b/apps/server/src/persistence/Layers/Sqlite.ts @@ -6,6 +6,7 @@ import * as SqlClient from "effect/unstable/sql/SqlClient"; import { runMigrations } from "../Migrations.ts"; import { ServerConfig } from "../../config.ts"; +import * as ServiceLauncherClient from "../../cloud/serviceLauncherClient.ts"; import { ProjectionThreadGoalRepositoryLive } from "../Services/ProjectionThreadGoals.ts"; import { makeRuntimeSqliteLayer } from "../RuntimeSqliteLayer.ts"; @@ -26,18 +27,22 @@ const repairMainMigrationLedger = Effect.fn("repairMainMigrationLedger")(functio `; }); -const setup = Layer.effectDiscard( - Effect.gen(function* () { - const sql = yield* SqlClient.SqlClient; - yield* sql`PRAGMA journal_mode = WAL;`; - yield* sql`PRAGMA foreign_keys = ON;`; - yield* repairMainMigrationLedger(); - yield* runMigrations(); - }), -); +const setup = (trial: boolean) => + Layer.effectDiscard( + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + yield* sql`PRAGMA foreign_keys = ON;`; + if (!trial) { + yield* sql`PRAGMA journal_mode = WAL;`; + yield* repairMainMigrationLedger(); + yield* runMigrations(); + } + }), + ); export const makeSqlitePersistenceLive = Effect.fn("makeSqlitePersistenceLive")(function* ( dbPath: string, + options?: { readonly trial?: boolean }, ) { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; @@ -46,7 +51,7 @@ export const makeSqlitePersistenceLive = Effect.fn("makeSqlitePersistenceLive")( return Layer.provideMerge( ProjectionThreadGoalRepositoryLive, Layer.provideMerge( - setup, + setup(options?.trial === true), makeRuntimeSqliteLayer({ filename: dbPath, spanAttributes: { @@ -60,9 +65,13 @@ export const makeSqlitePersistenceLive = Effect.fn("makeSqlitePersistenceLive")( export const SqlitePersistenceMemory = Layer.provideMerge( ProjectionThreadGoalRepositoryLive, - Layer.provideMerge(setup, makeRuntimeSqliteLayer({ filename: ":memory:" })), + Layer.provideMerge(setup(false), makeRuntimeSqliteLayer({ filename: ":memory:" })), ); export const layerConfig = Layer.unwrap( - Effect.map(Effect.service(ServerConfig), ({ dbPath }) => makeSqlitePersistenceLive(dbPath)), + Effect.gen(function* () { + const { dbPath } = yield* ServerConfig; + const launcher = yield* ServiceLauncherClient.resolveServiceLauncherMode(); + return makeSqlitePersistenceLive(dbPath, { trial: launcher.trial }); + }), ); diff --git a/apps/server/src/persistence/Migrations.ts b/apps/server/src/persistence/Migrations.ts index 95cb6b17f..b24aeb503 100644 --- a/apps/server/src/persistence/Migrations.ts +++ b/apps/server/src/persistence/Migrations.ts @@ -9,8 +9,8 @@ */ import * as Migrator from "effect/unstable/sql/Migrator"; -import * as Layer from "effect/Layer"; import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; // Import all migrations statically import Migration0001 from "./Migrations/001_OrchestrationEvents.ts"; @@ -97,6 +97,8 @@ export const migrationEntries = [ [35, "ProjectionThreadTitleRegeneration", Migration0035], ] as const; +export const migrationManifest = migrationEntries.map(([id, name]) => [id, name] as const); + export const makeMigrationLoader = (throughId?: number) => Migrator.fromRecord( Object.fromEntries( diff --git a/apps/server/src/provider/Layers/ClaudeCapabilitiesProbe.test.ts b/apps/server/src/provider/Layers/ClaudeCapabilitiesProbe.test.ts index ab6e59929..19907ece8 100644 --- a/apps/server/src/provider/Layers/ClaudeCapabilitiesProbe.test.ts +++ b/apps/server/src/provider/Layers/ClaudeCapabilitiesProbe.test.ts @@ -9,11 +9,27 @@ import * as Schema from "effect/Schema"; import { buildClaudeCapabilitiesProbeQueryOptions, CLAUDE_CAPABILITIES_PROBE_SETTING_SOURCES, + isLegacyClaudeModel, probeClaudeCapabilities, } from "./ClaudeProvider.ts"; const decodeClaudeSettings = Schema.decodeSync(ClaudeSettings); +it("keeps only the Claude 5 family out of legacy models", () => { + assert.deepStrictEqual( + ["claude-fable-5", "claude-opus-5", "claude-sonnet-5", "claude-opus-4-8"].map((model) => [ + model, + isLegacyClaudeModel(model), + ]), + [ + ["claude-fable-5", false], + ["claude-opus-5", false], + ["claude-sonnet-5", false], + ["claude-opus-4-8", true], + ], + ); +}); + it("isolates Claude capability probes without dropping workspace setting sources", () => { const abortController = new AbortController(); const options = buildClaudeCapabilitiesProbeQueryOptions({ diff --git a/apps/server/src/provider/Layers/ClaudeProvider.ts b/apps/server/src/provider/Layers/ClaudeProvider.ts index 96202ecd9..0e019f003 100644 --- a/apps/server/src/provider/Layers/ClaudeProvider.ts +++ b/apps/server/src/provider/Layers/ClaudeProvider.ts @@ -56,7 +56,13 @@ const MINIMUM_CLAUDE_FABLE_5_VERSION = "2.1.169"; const MINIMUM_CLAUDE_OPUS_4_8_VERSION = "2.1.154"; const MINIMUM_CLAUDE_OPUS_4_7_VERSION = "2.1.111"; -const BUILT_IN_MODELS: ReadonlyArray = [ +const CURRENT_CLAUDE_MODELS = new Set(["claude-fable-5", "claude-opus-5", "claude-sonnet-5"]); + +export function isLegacyClaudeModel(model: string): boolean { + return !CURRENT_CLAUDE_MODELS.has(model); +} + +const CLAUDE_MODEL_CATALOG: ReadonlyArray = [ { slug: "claude-fable-5", name: "Claude Fable 5", @@ -309,6 +315,10 @@ const BUILT_IN_MODELS: ReadonlyArray = [ }, ]; +const BUILT_IN_MODELS: ReadonlyArray = CLAUDE_MODEL_CATALOG.map((model) => + isLegacyClaudeModel(model.slug) ? { ...model, isLegacy: true } : model, +); + function supportsClaudeOpus5(version: string | null | undefined): boolean { return version ? compareSemverVersions(version, MINIMUM_CLAUDE_OPUS_5_VERSION) >= 0 : false; } diff --git a/apps/server/src/provider/Layers/CodexProvider.test.ts b/apps/server/src/provider/Layers/CodexProvider.test.ts index 2aeebdb2c..26e77f82a 100644 --- a/apps/server/src/provider/Layers/CodexProvider.test.ts +++ b/apps/server/src/provider/Layers/CodexProvider.test.ts @@ -1,6 +1,25 @@ import { assert, it } from "@effect/vitest"; -import { applyPreferredCodexDefaultModel, mapCodexModelCapabilities } from "./CodexProvider.ts"; +import { + applyPreferredCodexDefaultModel, + isLegacyCodexModel, + mapCodexModelCapabilities, +} from "./CodexProvider.ts"; + +it("keeps only the GPT-5.6 Codex family out of legacy models", () => { + assert.deepStrictEqual( + ["gpt-5.6-luna", "gpt-5.6-terra", "gpt-5.6-sol", "gpt-5.4"].map((model) => [ + model, + isLegacyCodexModel(model), + ]), + [ + ["gpt-5.6-luna", false], + ["gpt-5.6-terra", false], + ["gpt-5.6-sol", false], + ["gpt-5.4", true], + ], + ); +}); it("maps current Codex model capability fields", () => { const capabilities = mapCodexModelCapabilities({ diff --git a/apps/server/src/provider/Layers/CodexProvider.ts b/apps/server/src/provider/Layers/CodexProvider.ts index 1ed9c750c..5c0f76dff 100644 --- a/apps/server/src/provider/Layers/CodexProvider.ts +++ b/apps/server/src/provider/Layers/CodexProvider.ts @@ -62,6 +62,11 @@ const REASONING_EFFORT_LABELS: Readonly> = { }; const DEFAULT_SERVICE_TIER_ID = "default"; +const CURRENT_CODEX_MODELS = new Set(["gpt-5.6-luna", "gpt-5.6-terra", "gpt-5.6-sol"]); + +export function isLegacyCodexModel(model: string): boolean { + return !CURRENT_CODEX_MODELS.has(model); +} function reasoningEffortLabel(reasoningEffort: string): string { return REASONING_EFFORT_LABELS[reasoningEffort] ?? reasoningEffort; @@ -190,6 +195,7 @@ function parseCodexModelListResponse( name: toDisplayName(model), isCustom: false, ...(model.isDefault ? { isDefault: true } : {}), + ...(isLegacyCodexModel(model.model) ? { isLegacy: true } : {}), capabilities: mapCodexModelCapabilities(model), })); } diff --git a/apps/server/src/provider/Layers/ProviderSessionReaper.ts b/apps/server/src/provider/Layers/ProviderSessionReaper.ts index ca396b405..8eccd52fb 100644 --- a/apps/server/src/provider/Layers/ProviderSessionReaper.ts +++ b/apps/server/src/provider/Layers/ProviderSessionReaper.ts @@ -11,6 +11,7 @@ import { ProviderSessionReaper, type ProviderSessionReaperShape, } from "../Services/ProviderSessionReaper.ts"; +import { forkParked } from "../../serverActivation.ts"; import { ProviderService } from "../Services/ProviderService.ts"; const DEFAULT_INACTIVITY_THRESHOLD_MS = 30 * 60 * 1000; @@ -105,7 +106,7 @@ const makeProviderSessionReaper = (options?: ProviderSessionReaperLiveOptions) = const start: ProviderSessionReaperShape["start"] = () => Effect.gen(function* () { - yield* Effect.forkScoped( + yield* forkParked( sweep.pipe( Effect.catch((error: unknown) => Effect.logWarning("provider.session.reaper.sweep-failed", { diff --git a/apps/server/src/relay/AgentAwarenessRelay.ts b/apps/server/src/relay/AgentAwarenessRelay.ts index 58de98f1c..2a4de7eda 100644 --- a/apps/server/src/relay/AgentAwarenessRelay.ts +++ b/apps/server/src/relay/AgentAwarenessRelay.ts @@ -44,6 +44,7 @@ import { getOrCreateEnvironmentKeyPairFromSecretStore } from "../cloud/environme import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; import * as OrchestrationEngine from "../orchestration/Services/OrchestrationEngine.ts"; import * as ProjectionSnapshotQuery from "../orchestration/Services/ProjectionSnapshotQuery.ts"; +import { forkParked } from "../serverActivation.ts"; export class AgentAwarenessRelay extends Context.Service< AgentAwarenessRelay, @@ -599,12 +600,12 @@ export const make = Effect.gen(function* () { }); break; } - yield* Effect.forkScoped( + yield* forkParked( Effect.sleep("1 second").pipe( Effect.andThen(publishActiveThreadsOnceWhenConfigured(startupState !== "enabled")), ), ); - yield* Effect.forkScoped( + yield* forkParked( Stream.runForEach(orchestrationEngine.streamDomainEvents, (event) => { const threadId = eventThreadId(event); if (threadId === null) { diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index c5ba90c07..3506a8bc5 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -90,6 +90,7 @@ import * as ProviderRegistry from "./provider/Services/ProviderRegistry.ts"; import { makeManualOnlyProviderMaintenanceCapabilities } from "./provider/providerMaintenance.ts"; import * as ServerLifecycleEvents from "./serverLifecycleEvents.ts"; import * as ServerRuntimeStartup from "./serverRuntimeStartup.ts"; +import * as ServiceLauncherClient from "./cloud/serviceLauncherClient.ts"; import * as ServerSettings from "./serverSettings.ts"; import * as TerminalManager from "./terminal/Manager.ts"; import * as PreviewManager from "./preview/Manager.ts"; @@ -554,10 +555,13 @@ const buildAppUnderTest = (options?: { ), ); - const servedRoutesLayer = HttpRouter.serve(makeRoutesLayer, { - disableListenLog: true, - disableLogger: true, - }).pipe( + const servedRoutesLayer = HttpRouter.serve( + makeRoutesLayer.pipe(Layer.provide(ServiceLauncherClient.layer)), + { + disableListenLog: true, + disableLogger: true, + }, + ).pipe( Layer.provide( Layer.mock(Keybindings.Keybindings)({ loadConfigState: Effect.succeed({ @@ -1322,6 +1326,39 @@ const getWsServerUrl = ( }); it.layer(NodeServices.layer)("server router seam", (it) => { + it.effect("parks HTTP ingress until command readiness", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const staticDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-router-gate-" }); + yield* fileSystem.writeFileString(path.join(staticDir, "index.html"), "ready"); + const entered = yield* Deferred.make(); + const ready = yield* Deferred.make(); + const completed = yield* Deferred.make(); + + yield* buildAppUnderTest({ + config: { staticDir }, + layers: { + serverRuntimeStartup: { + awaitCommandReady: Deferred.succeed(entered, undefined).pipe( + Effect.andThen(Deferred.await(ready)), + ), + }, + }, + }); + const request = yield* HttpClient.get("/").pipe( + Effect.tap(() => Deferred.succeed(completed, undefined)), + Effect.forkChild, + ); + yield* Deferred.await(entered); + assert.isFalse(yield* Deferred.isDone(completed)); + + yield* Deferred.succeed(ready, undefined); + assert.equal((yield* Fiber.join(request)).status, 200); + assert.isTrue(yield* Deferred.isDone(completed)); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + it.effect("serves static index content for GET / when staticDir is configured", () => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index c8a1dd788..52062a4e5 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -1,5 +1,6 @@ import { EnvironmentHttpApi } from "@t3tools/contracts"; import * as Duration from "effect/Duration"; +import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Schedule from "effect/Schedule"; @@ -92,6 +93,7 @@ import * as CloudManagedEndpointRuntime from "./cloud/ManagedEndpointRuntime.ts" import * as CloudCliTokenManager from "./cloud/CliTokenManager.ts"; import * as CloudCliState from "./cloud/CliState.ts"; import * as ServerSelfUpdate from "./cloud/selfUpdate.ts"; +import * as ServiceLauncherClient from "./cloud/serviceLauncherClient.ts"; import * as ProcessDiagnostics from "./diagnostics/ProcessDiagnostics.ts"; import * as ProcessResourceMonitor from "./diagnostics/ProcessResourceMonitor.ts"; import * as TraceDiagnostics from "./diagnostics/TraceDiagnostics.ts"; @@ -113,6 +115,7 @@ import { orchestrationHttpApiLayer } from "./orchestration/http.ts"; import * as NetService from "@t3tools/shared/Net"; import * as RelayClient from "@t3tools/shared/relayClient"; import { disableTailscaleServe, ensureTailscaleServe } from "@t3tools/tailscale"; +import { forkParked, ServerActivation } from "./serverActivation.ts"; // Effect's default preemptive shutdown waits 20s before finalizing request scopes. // T3's primary transport is long-lived WebSocket RPC, whose Effect scope finalizer @@ -411,8 +414,12 @@ const RuntimeDependenciesLive = RuntimeCoreDependenciesLive.pipe( Layer.provide(NetService.layer), ); -const RuntimeServicesLive = ServerRuntimeStartup.layer.pipe( - Layer.provideMerge(RuntimeDependenciesLive), +const commandReadinessLayer = HttpRouter.middleware( + (httpEffect) => + Effect.flatMap(ServerRuntimeStartup.ServerRuntimeStartup, (startup) => + startup.awaitCommandReady.pipe(Effect.orDie, Effect.andThen(httpEffect)), + ), + { global: true }, ); export const makeRoutesLayer = Layer.mergeAll( @@ -433,6 +440,7 @@ export const makeRoutesLayer = Layer.mergeAll( ).pipe( Layer.provide(PreviewAutomationBroker.layer), Layer.provide(ServerSelfUpdate.layer), + Layer.provide(commandReadinessLayer), Layer.provide(browserApiCorsLayer), Layer.provide(httpCompressionLayer), ); @@ -440,6 +448,14 @@ export const makeRoutesLayer = Layer.mergeAll( export const makeServerLayer = Layer.unwrap( Effect.gen(function* () { const config = yield* ServerConfig.ServerConfig; + const activation = yield* Deferred.make(); + const awaitActivation = Deferred.await(activation); + const activationLayer = Layer.succeed(ServerActivation, awaitActivation); + const runtimeStateParked = yield* Deferred.make(); + const tailscaleParked = yield* Deferred.make(); + const cloudLinkParked = yield* Deferred.make(); + const routesReady = yield* Deferred.make(); + const launcherLayer = ServiceLauncherClient.layer; yield* fixPath(); @@ -453,6 +469,8 @@ export const makeServerLayer = Layer.unwrap( const runtimeStateLayer = Layer.effectDiscard( Effect.acquireRelease( Effect.gen(function* () { + yield* Deferred.succeed(runtimeStateParked, undefined).pipe(Effect.orDie); + yield* awaitActivation; const server = yield* HttpServer.HttpServer; const address = server.address; if (typeof address === "string" || !("port" in address)) { @@ -466,15 +484,26 @@ export const makeServerLayer = Layer.unwrap( yield* persistServerRuntimeState({ path: config.serverRuntimeStatePath, state, - }); + }).pipe( + Effect.catchCause((cause) => + Effect.logWarning("Failed to persist server runtime state", { cause }), + ), + ); }), - () => clearPersistedServerRuntimeState(config.serverRuntimeStatePath), + () => + clearPersistedServerRuntimeState(config.serverRuntimeStatePath).pipe( + Effect.catchCause((cause) => + Effect.logWarning("Failed to clear server runtime state", { cause }), + ), + ), ), ); const tailscaleServeLayer = config.tailscaleServeEnabled ? Layer.effectDiscard( Effect.acquireRelease( Effect.gen(function* () { + yield* Deferred.succeed(tailscaleParked, undefined).pipe(Effect.orDie); + yield* awaitActivation; const server = yield* HttpServer.HttpServer; const address = server.address; if (typeof address === "string" || !("port" in address)) { @@ -524,78 +553,89 @@ export const makeServerLayer = Layer.unwrap( : Layer.empty; const cloudDesiredLinkReconcileLayer = Layer.effectDiscard( Effect.gen(function* () { - if (!hasCloudPublicConfig) return; - // Idle Cloudflare tunnels are billed, so a stopping server releases its - // tunnel; the persisted desired link brings one back — same hostname, - // fresh tunnel — when the environment starts again. Registered even - // when no link is desired yet: a client can link a running server, and - // that tunnel needs the same disposal on shutdown. - yield* Effect.addFinalizer(() => - releaseManagedTunnelOnShutdown().pipe( - Effect.timeout("10 seconds"), - Effect.tap((released) => - released ? Effect.logInfo("Released the managed tunnel on shutdown") : Effect.void, - ), - Effect.catchCause((cause) => - Effect.logWarning( - "Failed to release the managed tunnel on shutdown; the next link reuses it", - { cause }, - ), - ), - Effect.asVoid, - ), - ); - if (!(yield* CloudCliState.readCliDesiredCloudLink)) return; - const server = yield* HttpServer.HttpServer; - const address = server.address; - if (typeof address === "string" || !("port" in address)) return; - yield* Effect.forkScoped( - Effect.sleep("250 millis").pipe( - Effect.andThen(reconcileDesiredCloudLink(`http://127.0.0.1:${address.port}`)), - // On reboot this races NIC/DNS bring-up, so back off exponentially - // (capped at 30s) instead of burning all retries in a second. - // Bounded overall so a permanently broken setup still surfaces the - // warning below. Bad-request/unauthorized/conflict are - // deterministic failures (malformed origin, not linked yet, linked - // to a different cloud account) that no amount of retrying - // converges. - Effect.retry({ - while: (error) => - error._tag !== "EnvironmentHttpBadRequestError" && - error._tag !== "EnvironmentHttpUnauthorizedError" && - error._tag !== "EnvironmentHttpConflictError", - schedule: Schedule.exponential("1 second").pipe( - Schedule.modifyDelay(({ duration }) => - Effect.succeed(Duration.min(duration, Duration.seconds(30))), + if (!hasCloudPublicConfig) { + yield* Deferred.succeed(cloudLinkParked, undefined).pipe(Effect.orDie); + return; + } + yield* forkParked( + Effect.gen(function* () { + // Only an activated runtime owns the tunnel cleanup finalizer. + yield* Effect.addFinalizer(() => + releaseManagedTunnelOnShutdown().pipe( + Effect.timeout("10 seconds"), + Effect.tap((released) => + released + ? Effect.logInfo("Released the managed tunnel on shutdown") + : Effect.void, + ), + Effect.catchCause((cause) => + Effect.logWarning( + "Failed to release the managed tunnel on shutdown; the next link reuses it", + { cause }, + ), ), - Schedule.upTo({ duration: "10 minutes" }), + Effect.asVoid, ), - }), - Effect.tap(() => Effect.logInfo("T3 Connect desired link reconciled on startup")), - Effect.catch((cause) => - Effect.logWarning("Failed to reconcile T3 Connect desired link on startup", { - cause, + ); + if (!(yield* CloudCliState.readCliDesiredCloudLink)) return; + const server = yield* HttpServer.HttpServer; + const address = server.address; + if (typeof address === "string" || !("port" in address)) return; + yield* Effect.sleep("250 millis").pipe( + Effect.andThen(reconcileDesiredCloudLink(`http://127.0.0.1:${address.port}`)), + Effect.retry({ + while: (error) => + error._tag !== "EnvironmentHttpBadRequestError" && + error._tag !== "EnvironmentHttpUnauthorizedError" && + error._tag !== "EnvironmentHttpConflictError", + schedule: Schedule.exponential("1 second").pipe( + Schedule.modifyDelay(({ duration }) => + Effect.succeed(Duration.min(duration, Duration.seconds(30))), + ), + Schedule.upTo({ duration: "10 minutes" }), + ), }), - ), - ), + Effect.tap(() => Effect.logInfo("T3 Connect desired link reconciled on startup")), + Effect.catch((cause) => + Effect.logWarning("Failed to reconcile T3 Connect desired link on startup", { + cause, + }), + ), + ); + }), ); + yield* Deferred.succeed(cloudLinkParked, undefined).pipe(Effect.orDie); }), ); - const serverApplicationLayer = Layer.mergeAll( - HttpRouter.serve( - Layer.unwrap( - Effect.gen(function* () { - const router = yield* HttpRouter.HttpRouter; - return makeRoutesLayer.pipe( - Layer.provide(Layer.succeed(HttpRouter.HttpRouter)(router.prefixed(config.basePath))), - ); - }), - ), - { - disableLogger: !config.logWebSocketEvents, - }, + const runtimeServicesLive = ServerRuntimeStartup.layerWithOptions({ + activate: Deferred.succeed(activation, undefined).pipe(Effect.asVoid), + abort: (error) => Deferred.die(activation, error).pipe(Effect.asVoid), + awaitAuxiliaryParked: Effect.all( + [ + Deferred.await(runtimeStateParked), + Deferred.await(cloudLinkParked), + Deferred.await(routesReady), + ...(config.tailscaleServeEnabled ? [Deferred.await(tailscaleParked)] : []), + ], + { concurrency: "unbounded" }, + ).pipe(Effect.asVoid), + }).pipe(Layer.provideMerge(RuntimeDependenciesLive), Layer.provide(launcherLayer)); + + const routesLayer = HttpRouter.serve( + Layer.unwrap( + Effect.gen(function* () { + const router = yield* HttpRouter.HttpRouter; + return makeRoutesLayer.pipe( + Layer.provide(launcherLayer), + Layer.provide(Layer.succeed(HttpRouter.HttpRouter)(router.prefixed(config.basePath))), + ); + }), ), + { disableLogger: !config.logWebSocketEvents }, + ).pipe(Layer.tap(() => Deferred.succeed(routesReady, undefined).pipe(Effect.orDie))); + const serverApplicationLayer = Layer.mergeAll( + routesLayer, httpListeningLayer, runtimeStateLayer, tailscaleServeLayer, @@ -604,7 +644,8 @@ export const makeServerLayer = Layer.unwrap( const serverConfigLayer = Layer.succeed(ServerConfig.ServerConfig, config); return serverApplicationLayer.pipe( - Layer.provideMerge(RuntimeServicesLive.pipe(Layer.provideMerge(serverConfigLayer))), + Layer.provideMerge(runtimeServicesLive.pipe(Layer.provideMerge(serverConfigLayer))), + Layer.provide(activationLayer), Layer.provideMerge(serverRelayBrokerTracingLayer), Layer.provideMerge(HttpResponseCompressionLive), Layer.provideMerge(HttpServerLive), @@ -616,5 +657,5 @@ export const makeServerLayer = Layer.unwrap( }), ); -// Important: Only `ServerConfig` should be provided by the CLI layer!!! Don't let other requirements leak into the launch layer. +// The CLI supplies configuration. export const runServer = Layer.launch(makeServerLayer); diff --git a/apps/server/src/serverActivation.test.ts b/apps/server/src/serverActivation.test.ts new file mode 100644 index 000000000..a4f942a95 --- /dev/null +++ b/apps/server/src/serverActivation.test.ts @@ -0,0 +1,23 @@ +import { expect, it } from "@effect/vitest"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; + +import { forkParked, ServerActivation } from "./serverActivation.ts"; + +it.effect("proves a root is parked before returning and releases it with one gate", () => + Effect.scoped( + Effect.gen(function* () { + const activation = yield* Deferred.make(); + const ran = yield* Deferred.make(); + + yield* forkParked(Deferred.succeed(ran, undefined)).pipe( + Effect.provideService(ServerActivation, Deferred.await(activation)), + ); + expect(yield* Deferred.isDone(ran)).toBe(false); + + yield* Deferred.succeed(activation, undefined); + yield* Deferred.await(ran); + expect(yield* Deferred.isDone(ran)).toBe(true); + }), + ), +); diff --git a/apps/server/src/serverActivation.ts b/apps/server/src/serverActivation.ts new file mode 100644 index 000000000..c068d55e7 --- /dev/null +++ b/apps/server/src/serverActivation.ts @@ -0,0 +1,26 @@ +import * as Context from "effect/Context"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import type * as Scope from "effect/Scope"; + +export class ServerActivation extends Context.Reference | undefined>( + "t3/serverActivation", + { defaultValue: () => undefined }, +) {} + +/** Forks a long-running root before commit and proves it is parked at the activation boundary. */ +export const forkParked = ( + effect: Effect.Effect, +): Effect.Effect => + Effect.gen(function* () { + const activation = yield* ServerActivation; + if (activation === undefined) { + yield* Effect.forkScoped(effect); + return; + } + const parked = yield* Deferred.make(); + yield* Effect.forkScoped( + Deferred.succeed(parked, undefined).pipe(Effect.andThen(activation), Effect.andThen(effect)), + ); + yield* Deferred.await(parked); + }); diff --git a/apps/server/src/serverRuntimeStartup.ts b/apps/server/src/serverRuntimeStartup.ts index 110917449..14abd51d1 100644 --- a/apps/server/src/serverRuntimeStartup.ts +++ b/apps/server/src/serverRuntimeStartup.ts @@ -34,6 +34,8 @@ import * as AnalyticsService from "./telemetry/AnalyticsService.ts"; import * as ServerEnvironment from "./environment/ServerEnvironment.ts"; import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts"; import * as ProviderSessionReaper from "./provider/Services/ProviderSessionReaper.ts"; +import { forkParked } from "./serverActivation.ts"; +import * as ServiceLauncherClient from "./cloud/serviceLauncherClient.ts"; import { formatHeadlessServeOutput, formatHostForUrl, @@ -288,151 +290,161 @@ const runStartupPhase = (phase: string, effect: Effect.Effect) Effect.withSpan(`server.startup.${phase}`), ); -export const make = Effect.gen(function* () { - const serverConfig = yield* ServerConfig.ServerConfig; - const keybindings = yield* Keybindings.Keybindings; - const orchestrationReactor = yield* OrchestrationReactor.OrchestrationReactor; - const providerSessionReaper = yield* ProviderSessionReaper.ProviderSessionReaper; - const lifecycleEvents = yield* ServerLifecycleEvents.ServerLifecycleEvents; - const serverSettings = yield* ServerSettings.ServerSettingsService; - const serverEnvironment = yield* ServerEnvironment.ServerEnvironment; - const crypto = yield* Crypto.Crypto; +interface StartupOptions { + readonly activate?: Effect.Effect; + readonly awaitAuxiliaryParked?: Effect.Effect; + readonly abort?: (error: ServerRuntimeStartupError) => Effect.Effect; +} - const commandGate = yield* makeCommandGate; - const httpListening = yield* Deferred.make(); - const reactorScope = yield* Scope.make("sequential"); - - yield* Effect.addFinalizer(() => Scope.close(reactorScope, Exit.void)); - - const startup = Effect.gen(function* () { - yield* Effect.logDebug("startup phase: starting keybindings runtime"); - yield* runStartupPhase( - "keybindings.start", - keybindings.start.pipe( - Effect.catch((error) => - Effect.logWarning("failed to start keybindings runtime", { - path: error.configPath, - detail: error.detail, - cause: error.cause, - }), +export const make = (options?: StartupOptions) => + Effect.gen(function* () { + const serverConfig = yield* ServerConfig.ServerConfig; + const keybindings = yield* Keybindings.Keybindings; + const orchestrationReactor = yield* OrchestrationReactor.OrchestrationReactor; + const providerSessionReaper = yield* ProviderSessionReaper.ProviderSessionReaper; + const lifecycleEvents = yield* ServerLifecycleEvents.ServerLifecycleEvents; + const serverSettings = yield* ServerSettings.ServerSettingsService; + const serverEnvironment = yield* ServerEnvironment.ServerEnvironment; + const crypto = yield* Crypto.Crypto; + const launcher = yield* ServiceLauncherClient.ServiceLauncherClient; + + const commandGate = yield* makeCommandGate; + const httpListening = yield* Deferred.make(); + const reactorScope = yield* Scope.make("sequential"); + + yield* Effect.addFinalizer(() => Scope.close(reactorScope, Exit.void)); + + const startup = Effect.gen(function* () { + yield* Effect.logDebug("startup phase: starting keybindings runtime"); + yield* runStartupPhase( + "keybindings.start", + keybindings.start.pipe( + Effect.catch((error) => + Effect.logWarning("failed to start keybindings runtime", { + path: error.configPath, + detail: error.detail, + cause: error.cause, + }), + ), ), - Effect.forkScoped, - ), - ); + ); - yield* Effect.logDebug("startup phase: starting server settings runtime"); - yield* runStartupPhase( - "settings.start", - serverSettings.start.pipe( - Effect.catch((error) => - Effect.logWarning("failed to start server settings runtime", { - path: error.settingsPath, - operation: error.operation, - providerInstanceId: error.providerInstanceId, - environmentVariable: error.environmentVariable, - cause: error.cause, - }), + yield* Effect.logDebug("startup phase: starting server settings runtime"); + yield* runStartupPhase( + "settings.start", + serverSettings.start.pipe( + Effect.catch((error) => + Effect.logWarning("failed to start server settings runtime", { + path: error.settingsPath, + operation: error.operation, + providerInstanceId: error.providerInstanceId, + environmentVariable: error.environmentVariable, + cause: error.cause, + }), + ), ), - Effect.forkScoped, - ), - ); + ); - yield* Effect.logDebug("startup phase: starting orchestration reactors"); - yield* runStartupPhase( - "reactors.start", - Effect.gen(function* () { - yield* orchestrationReactor.start().pipe(Scope.provide(reactorScope)); - yield* providerSessionReaper.start().pipe(Scope.provide(reactorScope)); - }), - ); + yield* Effect.logDebug("startup phase: parking orchestration roots at activation"); + yield* runStartupPhase( + "reactors.start", + Effect.gen(function* () { + yield* orchestrationReactor.start().pipe(Scope.provide(reactorScope)); + yield* providerSessionReaper.start().pipe(Scope.provide(reactorScope)); + }), + ); - const welcomeBase = yield* resolveWelcomeBase; - const environment = yield* serverEnvironment.getDescriptor; - yield* Effect.logDebug("startup phase: preparing welcome payload"); - yield* Effect.logDebug("startup phase: publishing welcome event", { - environmentId: environment.environmentId, - cwd: welcomeBase.cwd, - projectName: welcomeBase.projectName, - }); - yield* runStartupPhase( - "welcome.publish", - lifecycleEvents.publish({ - version: 1, - type: "welcome", - payload: { - environment, - ...welcomeBase, - }, - }), - ); + const welcomeBase = yield* resolveWelcomeBase; + const environment = yield* serverEnvironment.getDescriptor; + yield* Effect.logDebug("startup phase: preparing welcome payload"); + + if (serverConfig.autoBootstrapProjectFromCwd) { + yield* forkParked( + runStartupPhase( + "welcome.autobootstrap", + Effect.gen(function* () { + const bootstrapTargets = yield* resolveAutoBootstrapWelcomeTargets.pipe( + Effect.provideService(Crypto.Crypto, crypto), + ); + if (!bootstrapTargets.bootstrapProjectId && !bootstrapTargets.bootstrapThreadId) { + return; + } + + yield* Effect.logDebug("startup phase: publishing bootstrapped welcome event", { + environmentId: environment.environmentId, + cwd: welcomeBase.cwd, + projectName: welcomeBase.projectName, + bootstrapProjectId: bootstrapTargets.bootstrapProjectId, + bootstrapThreadId: bootstrapTargets.bootstrapThreadId, + }); + yield* lifecycleEvents.publish({ + version: 1, + type: "welcome", + payload: { + environment, + ...welcomeBase, + ...bootstrapTargets, + }, + }); + }).pipe( + Effect.catch((cause) => + Effect.logWarning("startup auto-bootstrap welcome failed", { + cause, + }), + ), + ), + ), + ); + } - if (serverConfig.autoBootstrapProjectFromCwd) { - yield* Effect.forkScoped( - runStartupPhase( - "welcome.autobootstrap", - Effect.gen(function* () { - const bootstrapTargets = yield* resolveAutoBootstrapWelcomeTargets.pipe( - Effect.provideService(Crypto.Crypto, crypto), + yield* forkParked( + Effect.gen(function* () { + yield* Effect.logDebug("startup phase: recording startup heartbeat"); + yield* recordStartupHeartbeat.pipe( + Effect.annotateSpans({ "startup.phase": "heartbeat.record" }), + Effect.withSpan("server.startup.heartbeat.record"), + Effect.ignoreCause({ log: true }), + ); + if (serverConfig.startupPresentation === "headless") { + const accessInfo = yield* issueHeadlessServeAccessInfo(); + yield* runStartupPhase( + "headless.output", + Console.log(formatHeadlessServeOutput(accessInfo)), ); - if (!bootstrapTargets.bootstrapProjectId && !bootstrapTargets.bootstrapThreadId) { - return; + } else { + const startupBrowserTarget = yield* resolveStartupBrowserTarget; + if (serverConfig.mode !== "desktop") { + yield* Effect.logInfo( + "Authentication required. Open T3 Code using the pairing URL.", + ).pipe(Effect.annotateLogs({ pairingUrl: startupBrowserTarget })); } + yield* runStartupPhase("browser.open", maybeOpenBrowser(startupBrowserTarget)); + } + }), + ); - yield* Effect.logDebug("startup phase: publishing bootstrapped welcome event", { - environmentId: environment.environmentId, - cwd: welcomeBase.cwd, - projectName: welcomeBase.projectName, - bootstrapProjectId: bootstrapTargets.bootstrapProjectId, - bootstrapThreadId: bootstrapTargets.bootstrapThreadId, - }); - yield* lifecycleEvents.publish({ - version: 1, - type: "welcome", - payload: { - environment, - ...welcomeBase, - ...bootstrapTargets, - }, - }); - }).pipe( - Effect.catch((cause) => - Effect.logWarning("startup auto-bootstrap welcome failed", { - cause, - }), - ), - ), - ), + yield* Effect.logDebug("startup phase: waiting for http listener"); + yield* runStartupPhase("http.wait", Deferred.await(httpListening)); + yield* runStartupPhase( + "auxiliary-roots.parked", + options?.awaitAuxiliaryParked ?? Effect.void, ); - } - }).pipe( - Effect.annotateSpans({ - "server.mode": serverConfig.mode, - "server.port": serverConfig.port, - "server.host": serverConfig.host ?? "default", - }), - Effect.withSpan("server.startup", { kind: "server", root: true }), - ); - yield* Effect.forkScoped( - Effect.gen(function* () { - const startupExit = yield* Effect.exit(startup); - if (Exit.isFailure(startupExit)) { - const error = new ServerRuntimeStartupError({ - mode: serverConfig.mode, - host: serverConfig.host ?? null, - port: serverConfig.port, - cause: startupExit.cause, - }); - yield* Effect.logError("server runtime startup failed", { cause: startupExit.cause }); - yield* commandGate.failCommandReady(error); - return; - } + // This is the prepared boundary. Every dependency has been acquired and + // every runtime root has confirmed that it is parked before this request. + const updateOutcome = yield* launcher.prepareTrial; + yield* runStartupPhase( + "welcome.publish", + lifecycleEvents.publish({ + version: 1, + type: "welcome", + payload: { environment, ...welcomeBase }, + }), + ); + yield* options?.activate ?? Effect.void; yield* Effect.logDebug("Accepting commands"); yield* commandGate.signalCommandReady; - yield* Effect.logDebug("startup phase: waiting for http listener"); - yield* runStartupPhase("http.wait", Deferred.await(httpListening)); - yield* Effect.logDebug("startup phase: publishing ready event"); yield* runStartupPhase( "ready.publish", lifecycleEvents.publish({ @@ -440,39 +452,49 @@ export const make = Effect.gen(function* () { type: "ready", payload: { at: DateTime.formatIso(yield* DateTime.now), - environment: yield* serverEnvironment.getDescriptor, + environment, + ...(updateOutcome === undefined ? {} : { updateOutcome }), }, }), ); - - yield* Effect.logDebug("startup phase: recording startup heartbeat"); - yield* launchStartupHeartbeat; - if (serverConfig.startupPresentation === "headless") { - yield* Effect.logDebug("startup phase: headless access info"); - const accessInfo = yield* issueHeadlessServeAccessInfo(); - yield* runStartupPhase( - "headless.output", - Console.log(formatHeadlessServeOutput(accessInfo)), - ); - } else { - yield* Effect.logDebug("startup phase: browser open check"); - const startupBrowserTarget = yield* resolveStartupBrowserTarget; - if (serverConfig.mode !== "desktop") { - yield* Effect.logInfo( - "Authentication required. Open T3 Code using the pairing URL.", - ).pipe(Effect.annotateLogs({ pairingUrl: startupBrowserTarget })); - } - yield* runStartupPhase("browser.open", maybeOpenBrowser(startupBrowserTarget)); - } yield* Effect.logDebug("startup phase: complete"); - }), - ); + }).pipe( + Effect.annotateSpans({ + "server.mode": serverConfig.mode, + "server.port": serverConfig.port, + "server.host": serverConfig.host ?? "default", + }), + Effect.withSpan("server.startup", { kind: "server", root: true }), + ); - return { - awaitCommandReady: commandGate.awaitCommandReady, - markHttpListening: Deferred.succeed(httpListening, undefined), - enqueueCommand: commandGate.enqueueCommand, - } satisfies ServerRuntimeStartup["Service"]; -}); + yield* Effect.forkScoped( + Effect.exit(startup).pipe( + Effect.flatMap((startupExit) => { + if (Exit.isSuccess(startupExit)) return Effect.void; + const error = new ServerRuntimeStartupError({ + mode: serverConfig.mode, + host: serverConfig.host ?? null, + port: serverConfig.port, + cause: startupExit.cause, + }); + return Effect.logError("server runtime startup failed", { + cause: startupExit.cause, + }).pipe( + Effect.andThen(commandGate.failCommandReady(error)), + Effect.andThen(options?.abort?.(error) ?? Effect.void), + ); + }), + ), + ); + + return { + awaitCommandReady: commandGate.awaitCommandReady, + markHttpListening: Deferred.succeed(httpListening, undefined), + enqueueCommand: commandGate.enqueueCommand, + } satisfies ServerRuntimeStartup["Service"]; + }); + +export const layerWithOptions = (options?: StartupOptions) => + Layer.effect(ServerRuntimeStartup, make(options)); -export const layer = Layer.effect(ServerRuntimeStartup, make); +export const layer = layerWithOptions(); diff --git a/apps/server/src/service-launcher.ts b/apps/server/src/service-launcher.ts new file mode 100644 index 000000000..105212451 --- /dev/null +++ b/apps/server/src/service-launcher.ts @@ -0,0 +1 @@ +import "./serviceLauncher.ts"; diff --git a/apps/server/src/serviceLauncher.test.ts b/apps/server/src/serviceLauncher.test.ts new file mode 100644 index 000000000..21f3618d5 --- /dev/null +++ b/apps/server/src/serviceLauncher.test.ts @@ -0,0 +1,199 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; + +import { Launcher, readServiceState, writeServiceState } from "./serviceLauncher.ts"; +import { + compareExactServiceVersions, + decodeServiceState, + isExactServiceVersion, +} from "./cloud/serviceProtocol.ts"; + +it("accepts only exact semantic versions", () => { + for (const version of ["0.0.0", "1.2.3", "1.2.3-alpha.1", "1.2.3-0", "1.2.3+001"]) { + assert.isTrue(isExactServiceVersion(version), version); + } + for (const version of ["latest", "01.2.3", "1.2.3-01", "1.2.3-alpha..1", "1.2.3+."]) { + assert.isFalse(isExactServiceVersion(version), version); + } +}); + +it("orders exact semantic versions without treating build metadata as precedence", () => { + assert.equal(compareExactServiceVersions("1.2.3", "1.2.3"), 0); + assert.equal(compareExactServiceVersions("1.2.4", "1.2.3"), 1); + assert.equal(compareExactServiceVersions("2.0.0-alpha.1", "2.0.0-alpha.2"), -1); + assert.equal(compareExactServiceVersions("2.0.0-alpha.2", "2.0.0-alpha.beta"), -1); + assert.equal(compareExactServiceVersions("2.0.0-alpha-beta", "2.0.0-alpha-alpha"), 1); + assert.equal(compareExactServiceVersions("2.0.0", "2.0.0-rc.1"), 1); + assert.equal(compareExactServiceVersions("2.0.0+one", "2.0.0+two"), 0); +}); + +it("rejects contradictory service state", () => { + assert.isUndefined( + decodeServiceState({ + protocol: 1, + activeVersion: "0.0.31", + update: { + id: "update-1", + fromVersion: "0.0.30", + targetVersion: "0.0.32", + status: "pending", + }, + }), + ); + + assert.isUndefined( + decodeServiceState({ + protocol: 1, + activeVersion: "1.0.0", + update: { + id: "update-2", + fromVersion: "1.0.0", + targetVersion: "0.9.0", + status: "pending", + }, + }), + ); +}); + +it.layer(NodeServices.layer)("service state persistence", (it) => { + it.effect("durably replaces and strictly reads one state document", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-service-launcher-test-" }); + const statePath = path.join(root, "runtime", "service-state.json"); + const state = { + protocol: 1, + activeVersion: "0.0.31", + } as const; + + yield* Effect.promise(() => writeServiceState(statePath, state)); + assert.deepEqual(yield* Effect.promise(() => readServiceState(statePath)), state); + }), + ); + + it.effect("serializes shutdown with launcher recovery", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-service-launcher-stop-" }); + const statePath = path.join(root, "runtime", "service-state.json"); + const versionDir = path.join(root, "runtime", "versions", "1.0.0"); + const entryPath = path.join(versionDir, "node_modules", "t3", "dist", "bin.mjs"); + yield* fs.makeDirectory(path.dirname(entryPath), { recursive: true }); + yield* fs.writeFileString(entryPath, "setInterval(() => {}, 1_000);\n"); + yield* fs.writeFileString(path.join(versionDir, ".install-complete"), "1.0.0\n"); + yield* Effect.promise(() => + writeServiceState(statePath, { + protocol: 1, + activeVersion: "1.0.0", + }), + ); + + const launcher = new Launcher(root, yield* Effect.promise(() => readServiceState(statePath))); + const running = launcher.run(); + yield* Effect.promise(() => launcher.stop("SIGTERM")); + yield* Effect.promise(() => running); + }), + ); + + it.effect("commits only after the trial reports prepared", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-service-launcher-flow-" }); + const statePath = path.join(root, "runtime", "service-state.json"); + const childSource = ` +const context = JSON.parse(process.env.T3_SERVICE_LAUNCHER_CONTEXT); +if (context.update?.status === "pending") { + process.send({ type: "prepared", updateId: context.update.id }); + process.on("message", (message) => { + if (message.type === "committed") process.exit(0); + }); +} else if (context.update === undefined) { + process.send({ type: "request-update", targetVersion: "1.1.0" }); + setInterval(() => {}, 1_000); +} else { + process.exit(0); +} +`; + for (const version of ["1.0.0", "1.1.0"]) { + const versionDir = path.join(root, "runtime", "versions", version); + const entryPath = path.join(versionDir, "node_modules", "t3", "dist", "bin.mjs"); + yield* fs.makeDirectory(path.dirname(entryPath), { recursive: true }); + yield* fs.writeFileString(entryPath, childSource); + yield* fs.writeFileString(path.join(versionDir, ".install-complete"), `${version}\n`); + } + yield* Effect.promise(() => + writeServiceState(statePath, { + protocol: 1, + activeVersion: "1.0.0", + }), + ); + + const launcher = new Launcher(root, yield* Effect.promise(() => readServiceState(statePath))); + yield* Effect.promise(() => + launcher.run().then( + () => Promise.reject(new Error("launcher unexpectedly completed")), + () => Promise.resolve(), + ), + ); + + const state = yield* Effect.promise(() => readServiceState(statePath)); + assert.equal(state.activeVersion, "1.1.0"); + assert.equal(state.update?.status, "committed"); + }), + ); + + it.effect("rolls back a trial that reports the wrong update ID", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-service-launcher-rollback-" }); + const statePath = path.join(root, "runtime", "service-state.json"); + const childSource = ` +const context = JSON.parse(process.env.T3_SERVICE_LAUNCHER_CONTEXT); +if (context.update?.status === "pending") { + process.send({ type: "prepared", updateId: "wrong-update" }); +} else if (context.update === undefined) { + process.send({ type: "request-update", targetVersion: "1.1.0" }); + setInterval(() => {}, 1_000); +} else { + process.exit(0); +} +`; + for (const version of ["1.0.0", "1.1.0"]) { + const versionDir = path.join(root, "runtime", "versions", version); + const entryPath = path.join(versionDir, "node_modules", "t3", "dist", "bin.mjs"); + yield* fs.makeDirectory(path.dirname(entryPath), { recursive: true }); + yield* fs.writeFileString(entryPath, childSource); + yield* fs.writeFileString(path.join(versionDir, ".install-complete"), `${version}\n`); + } + yield* Effect.promise(() => + writeServiceState(statePath, { + protocol: 1, + activeVersion: "1.0.0", + }), + ); + + const launcher = new Launcher(root, yield* Effect.promise(() => readServiceState(statePath))); + yield* Effect.promise(() => + launcher.run().then( + () => Promise.reject(new Error("launcher unexpectedly completed")), + () => Promise.resolve(), + ), + ); + + const state = yield* Effect.promise(() => readServiceState(statePath)); + assert.equal(state.activeVersion, "1.0.0"); + assert.equal(state.update?.status, "rolled-back"); + assert.equal( + state.update?.status === "rolled-back" ? state.update.reason : undefined, + "invalid-prepared", + ); + }), + ); +}); diff --git a/apps/server/src/serviceLauncher.ts b/apps/server/src/serviceLauncher.ts new file mode 100644 index 000000000..d7ca33578 --- /dev/null +++ b/apps/server/src/serviceLauncher.ts @@ -0,0 +1,458 @@ +// @effect-diagnostics nodeBuiltinImport:off +// @effect-diagnostics globalDate:off +// @effect-diagnostics globalTimers:off +// This file is shipped as a standalone bundle and copied to a stable path by +// `t3 service update`. Keep runtime imports limited to Node built-ins. +import * as NodeChildProcess from "node:child_process"; +import * as NodeCrypto from "node:crypto"; +import * as NodeFSP from "node:fs/promises"; +import * as NodePath from "node:path"; + +import type { + PendingServiceUpdate, + ServiceLauncherChildMessage, + ServiceLauncherContext, + ServiceLauncherParentMessage, + ServiceState, + ServiceUpdateRecord, +} from "./cloud/serviceProtocol.ts"; +import { + compareExactServiceVersions, + decodeServiceLauncherChildMessage, + isExactServiceVersion, + parseServiceState, + SERVICE_LAUNCHER_CONTEXT_ENV, + SERVICE_LAUNCHER_PROTOCOL, + SERVICE_STATE_FILE, +} from "./cloud/serviceProtocol.ts"; + +const HANDOFF_DELAY_MS = 2_000; +const PREPARED_TIMEOUT_MS = 120_000; +const TERMINATE_GRACE_MS = 5_000; + +type TerminalStatus = "committed" | "rolled-back" | "failed"; +type ChildRole = "active" | "trial"; + +interface ManagedChild { + readonly version: string; + role: ChildRole; + readonly process: NodeChildProcess.ChildProcess; +} + +const runtimePaths = (baseDir: string, version: string) => { + const versionDir = NodePath.join(baseDir, "runtime", "versions", version); + return { + versionDir, + entryPath: NodePath.join(versionDir, "node_modules", "t3", "dist", "bin.mjs"), + sentinelPath: NodePath.join(versionDir, ".install-complete"), + }; +}; + +export async function readServiceState(filePath: string): Promise { + const contents = await NodeFSP.readFile(filePath, "utf8"); + const state = parseServiceState(contents); + if (state === undefined) throw new Error("Service state is invalid or unsupported."); + return state; +} + +/** Durable same-directory replacement used for every runtime state transition. */ +export async function writeServiceState(filePath: string, state: ServiceState): Promise { + const directory = NodePath.dirname(filePath); + await NodeFSP.mkdir(directory, { recursive: true, mode: 0o700 }); + const tempPath = NodePath.join( + directory, + `.${NodePath.basename(filePath)}.${process.pid}.${NodeCrypto.randomUUID()}`, + ); + let handle: NodeFSP.FileHandle | undefined; + try { + handle = await NodeFSP.open(tempPath, "wx", 0o600); + await handle.writeFile(`${JSON.stringify(state, null, 2)}\n`, "utf8"); + await handle.sync(); + await handle.close(); + handle = undefined; + await NodeFSP.rename(tempPath, filePath); + const directoryHandle = await NodeFSP.open(directory, "r"); + try { + await directoryHandle.sync(); + } finally { + await directoryHandle.close(); + } + } finally { + await handle?.close().catch(() => undefined); + await NodeFSP.rm(tempPath, { force: true }).catch(() => undefined); + } +} + +async function runtimeExists(baseDir: string, version: string): Promise { + const paths = runtimePaths(baseDir, version); + try { + const [entry, sentinel] = await Promise.all([ + NodeFSP.stat(paths.entryPath), + NodeFSP.readFile(paths.sentinelPath, "utf8"), + ]); + return entry.isFile() && sentinel.trim() === version; + } catch { + return false; + } +} + +function terminalUpdate(input: { + readonly pending: PendingServiceUpdate; + readonly status: S; + readonly reason?: string; +}): Exclude & { readonly status: S } { + return { + id: input.pending.id, + fromVersion: input.pending.fromVersion, + targetVersion: input.pending.targetVersion, + status: input.status, + ...(input.reason === undefined ? {} : { reason: input.reason }), + }; +} + +function sendMessage( + child: NodeChildProcess.ChildProcess, + message: ServiceLauncherParentMessage, +): Promise { + return new Promise((resolve, reject) => { + if (!child.connected || child.send === undefined) { + reject(new Error("service child IPC is disconnected.")); + return; + } + child.send(message, (error) => (error === null ? resolve() : reject(error))); + }); +} + +function waitForExit(child: NodeChildProcess.ChildProcess): Promise { + if (child.exitCode !== null || child.signalCode !== null) return Promise.resolve(); + return new Promise((resolve) => child.once("exit", () => resolve())); +} + +async function terminateChild( + child: NodeChildProcess.ChildProcess, + signal: NodeJS.Signals = "SIGTERM", +): Promise { + if (child.exitCode !== null || child.signalCode !== null) return; + child.kill(signal); + const force = setTimeout(() => child.kill("SIGKILL"), TERMINATE_GRACE_MS); + try { + await waitForExit(child); + } finally { + clearTimeout(force); + } +} + +export class Launcher { + readonly #baseDir: string; + readonly #statePath: string; + #state: ServiceState; + #child: ManagedChild | null = null; + #timer: NodeJS.Timeout | undefined; + #transitions: Promise = Promise.resolve(); + #stopping = false; + #done = false; + readonly #completion = Promise.withResolvers(); + + constructor(baseDir: string, state: ServiceState) { + this.#baseDir = baseDir; + this.#statePath = NodePath.join(baseDir, "runtime", SERVICE_STATE_FILE); + this.#state = state; + } + + async run(): Promise { + const onSigterm = () => void this.stop("SIGTERM"); + const onSigint = () => void this.stop("SIGINT"); + process.once("SIGTERM", onSigterm); + process.once("SIGINT", onSigint); + try { + this.#enqueue(() => this.#recover()); + await this.#completion.promise; + } finally { + process.off("SIGTERM", onSigterm); + process.off("SIGINT", onSigint); + } + } + + #enqueue(transition: () => Promise): void { + this.#transitions = this.#transitions + .then(transition, transition) + .catch((cause: unknown) => + this.#fatal(cause instanceof Error ? cause : new Error(String(cause))), + ); + } + + async #fatal(error: Error): Promise { + if (this.#done) return; + this.#done = true; + this.#stopping = true; + this.#clearTimer(); + const child = this.#child?.process; + this.#child = null; + if (child !== undefined) await terminateChild(child); + this.#completion.reject(error); + } + + async stop(signal: NodeJS.Signals): Promise { + if (this.#stopping) { + await this.#completion.promise.catch(() => undefined); + return; + } + this.#stopping = true; + this.#clearTimer(); + this.#enqueue(async () => { + const child = this.#child?.process; + this.#child = null; + if (child !== undefined) await terminateChild(child, signal); + this.#done = true; + this.#completion.resolve(); + }); + await this.#completion.promise.catch(() => undefined); + } + + #clearTimer(): void { + clearTimeout(this.#timer); + this.#timer = undefined; + } + + async #recover(): Promise { + const update = this.#state.update; + if (update?.status !== "pending") { + await this.#startChild(this.#state.activeVersion, "active", update); + return; + } + if (!(await runtimeExists(this.#baseDir, update.targetVersion))) { + await this.#returnToPrevious(update, "failed", "target-runtime-missing"); + return; + } + await this.#startTrial(update); + } + + async #startTrial(pending: PendingServiceUpdate): Promise { + try { + await this.#startChild(pending.targetVersion, "trial", pending); + } catch { + await this.#returnToPrevious(pending, "failed", "candidate-start-failed"); + } + } + + async #startChild(version: string, role: ChildRole, update?: ServiceUpdateRecord): Promise { + if (this.#stopping) return; + if (!(await runtimeExists(this.#baseDir, version))) { + throw new Error(`Selected t3@${version} runtime is missing or incomplete.`); + } + if (this.#stopping) return; + const paths = runtimePaths(this.#baseDir, version); + const context: ServiceLauncherContext = { + protocol: SERVICE_LAUNCHER_PROTOCOL, + childVersion: version, + ...(update === undefined ? {} : { update }), + }; + const child = NodeChildProcess.spawn(process.execPath, [paths.entryPath, "serve"], { + env: { ...process.env, [SERVICE_LAUNCHER_CONTEXT_ENV]: JSON.stringify(context) }, + stdio: ["inherit", "inherit", "inherit", "ipc"], + }); + await new Promise((resolve, reject) => { + const onError = (error: Error) => reject(error); + child.once("error", onError); + child.once("spawn", () => { + child.removeListener("error", onError); + child.on("error", (error) => this.#enqueue(() => Promise.reject(error))); + resolve(); + }); + }); + if (this.#stopping) { + await terminateChild(child); + return; + } + + const managed: ManagedChild = { + version, + role, + process: child, + }; + this.#child = managed; + child.on("message", (value) => { + const message = decodeServiceLauncherChildMessage(value); + if (message !== undefined) this.#enqueue(() => this.#handleMessage(managed, message)); + }); + child.once("exit", (code, signal) => + this.#enqueue(() => this.#handleExit(managed, code, signal)), + ); + + if (role === "trial") { + this.#timer = setTimeout( + () => this.#enqueue(() => this.#handlePreparedTimeout(managed)), + PREPARED_TIMEOUT_MS, + ); + } + } + + async #handleMessage(child: ManagedChild, message: ServiceLauncherChildMessage): Promise { + if (this.#child !== child || this.#stopping) return; + if (message.type === "request-update") { + await this.#handleUpdateRequest(child, message); + return; + } + await this.#handlePrepared(child, message.updateId); + } + + async #handleUpdateRequest( + child: ManagedChild, + message: Extract, + ): Promise { + const reject = (reason: string) => + sendMessage(child.process, { type: "update-rejected", reason }); + if (child.role !== "active") { + await reject("Only the active server can request an update."); + return; + } + if (child.version !== this.#state.activeVersion) { + await reject("The requesting server is not the selected active version."); + return; + } + if (this.#state.update?.status === "pending") { + await reject("Another server update is already pending."); + return; + } + if (!isExactServiceVersion(message.targetVersion)) { + await reject("The requested target is not an exact version."); + return; + } + if (compareExactServiceVersions(message.targetVersion, child.version) <= 0) { + await reject("Remote updates must select a newer server version."); + return; + } + if (!(await runtimeExists(this.#baseDir, message.targetVersion))) { + await reject("The requested target runtime is missing or incomplete."); + return; + } + + const pending: PendingServiceUpdate = { + id: NodeCrypto.randomUUID(), + fromVersion: child.version, + targetVersion: message.targetVersion, + status: "pending", + }; + const next: ServiceState = { ...this.#state, update: pending }; + await writeServiceState(this.#statePath, next); + this.#state = next; + await sendMessage(child.process, { type: "update-accepted", updateId: pending.id }); + this.#timer = setTimeout(() => this.#enqueue(() => this.#beginTrial(child)), HANDOFF_DELAY_MS); + } + + async #beginTrial(child: ManagedChild): Promise { + const pending = this.#state.update; + if (this.#child !== child || child.role !== "active" || pending?.status !== "pending") { + return; + } + this.#timer = undefined; + this.#child = null; + await terminateChild(child.process); + await this.#startTrial(pending); + } + + async #handlePrepared(child: ManagedChild, updateId: string): Promise { + const pending = this.#state.update; + if ( + child.role !== "trial" || + pending?.status !== "pending" || + pending.id !== updateId || + pending.targetVersion !== child.version + ) { + if (child.role === "trial" && pending?.status === "pending") { + await this.#returnToPrevious(pending, "rolled-back", "invalid-prepared", child); + return; + } + throw new Error("Trial child reported prepared for an unexpected update."); + } + this.#clearTimer(); + const committed = terminalUpdate({ pending, status: "committed" }); + const next: ServiceState = { + ...this.#state, + activeVersion: pending.targetVersion, + update: committed, + }; + await writeServiceState(this.#statePath, next); + this.#state = next; + child.role = "active"; + await sendMessage(child.process, { type: "committed", updateId: committed.id }); + } + + async #handlePreparedTimeout(child: ManagedChild): Promise { + const pending = this.#state.update; + if (this.#child !== child || child.role !== "trial" || pending?.status !== "pending") { + return; + } + this.#timer = undefined; + await this.#returnToPrevious(pending, "rolled-back", "prepared-timeout", child); + } + + async #handleExit( + child: ManagedChild, + code: number | null, + signal: NodeJS.Signals | null, + ): Promise { + if (this.#child !== child || this.#stopping) return; + this.#child = null; + if (child.role === "trial") { + this.#clearTimer(); + const pending = this.#state.update; + if (pending?.status !== "pending") { + throw new Error("Trial child exited without matching pending state."); + } + await this.#returnToPrevious( + pending, + "rolled-back", + `candidate-exited:${String(code ?? signal ?? "unknown")}`, + ); + return; + } + + this.#clearTimer(); + const pending = this.#state.update; + if (pending?.status === "pending") { + await this.#startTrial(pending); + return; + } + throw new Error(`Active child exited unexpectedly (${String(code ?? signal ?? "unknown")}).`); + } + + async #returnToPrevious( + pending: PendingServiceUpdate, + status: "rolled-back" | "failed", + reason: string, + child?: ManagedChild, + ): Promise { + const outcome = terminalUpdate({ pending, status, reason }); + const next: ServiceState = { + ...this.#state, + activeVersion: pending.fromVersion, + update: outcome, + }; + await writeServiceState(this.#statePath, next); + this.#state = next; + if (child !== undefined) { + this.#child = null; + await terminateChild(child.process); + } + await this.#startChild(next.activeVersion, "active", outcome); + } +} + +async function main(): Promise { + const baseDir = process.env.T3CODE_HOME?.trim(); + if (baseDir === undefined || baseDir === "") { + throw new Error("T3CODE_HOME is required by the T3 Code service launcher."); + } + const statePath = NodePath.join(baseDir, "runtime", SERVICE_STATE_FILE); + const state = await readServiceState(statePath); + await new Launcher(baseDir, state).run(); +} + +if (import.meta.main) { + main().catch((cause: unknown) => { + const error = cause instanceof Error ? cause : new Error(String(cause)); + process.stderr.write(`[service-launcher] ${error.message}\n`); + process.exitCode = 1; + }); +} diff --git a/apps/web/index.html b/apps/web/index.html index dadef17d3..eccee9287 100644 --- a/apps/web/index.html +++ b/apps/web/index.html @@ -7,14 +7,14 @@ content="width=device-width, initial-scale=1.0, viewport-fit=cover, interactive-widget=resizes-content" /> - - + +