diff --git a/apps/server/src/cloud/serviceProtocol.ts b/apps/server/src/cloud/serviceProtocol.ts index 8958c945aab84..7528caa50f720 100644 --- a/apps/server/src/cloud/serviceProtocol.ts +++ b/apps/server/src/cloud/serviceProtocol.ts @@ -203,6 +203,24 @@ export function parseServiceState(value: string): ServiceState | undefined { } } +export function parseLegacyServiceState(value: string) { + try { + const parsed: unknown = JSON.parse(value); + if ( + !isRecord(parsed) || + Object.keys(parsed).length !== 2 || + parsed.protocol !== 2 || + typeof parsed.activeVersion !== "string" || + !isExactServiceVersion(parsed.activeVersion) + ) { + return undefined; + } + return { protocol: 2, activeVersion: parsed.activeVersion } as const; + } catch { + return undefined; + } +} + export function serviceStateHasPendingUpdate(value: string): boolean { try { const parsed: unknown = JSON.parse(value); diff --git a/apps/server/src/serviceLauncher.test.ts b/apps/server/src/serviceLauncher.test.ts index 3304211d9e7c6..dc86e7b08ad0b 100644 --- a/apps/server/src/serviceLauncher.test.ts +++ b/apps/server/src/serviceLauncher.test.ts @@ -7,7 +7,7 @@ import * as NodeFSP from "node:fs/promises"; import * as NodeSqlite from "node:sqlite"; import * as NodeServices from "@effect/platform-node/NodeServices"; -import { assert, it } from "@effect/vitest"; +import { assert, expect, it } from "@effect/vitest"; import { SERVER_EXIT_CODE_STATE_DIR_OWNED } from "@t3tools/contracts"; import * as HostProcess from "@t3tools/shared/HostProcess"; import * as Effect from "effect/Effect"; @@ -19,6 +19,7 @@ import { renderBootServicePlist, renderBootServiceUnit } from "./cloud/bootServi import { databaseBackupBlocker, Launcher, + main, readServiceEnvironment, type BackupSpaceProbe, readServiceState, @@ -139,6 +140,15 @@ const writeFakeRuntime = ( return entryPath; }); +const runLauncher = async (root: string) => { + vi.stubEnv("T3CODE_HOME", root); + try { + await main(); + } finally { + vi.unstubAllEnvs(); + } +}; + const fakeServiceUnit = ( fs: FileSystem.FileSystem, path: Path.Path, @@ -212,6 +222,102 @@ it.layer(NodeServices.layer)("service state persistence", (it) => { }), ); + it.effect("adopts a legacy install record without rewriting it", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-service-launcher-migrate-" }); + const statePath = path.join(root, "runtime", "service-state.json"); + const activeVersion = "0.0.43-nightly.20260919.1962+build.1"; + const versionDir = path.join(root, "runtime", "versions", activeVersion); + yield* writeFakeRuntime( + fs, + path, + versionDir, + ` +import { writeFileSync } from "node:fs"; +writeFileSync(new URL("./started", import.meta.url), ""); +process.exit(0); +`, + ); + const legacyContents = `{ "protocol": 2, "activeVersion": "${activeVersion}" }`; + yield* fs.writeFileString(statePath, legacyContents); + const restartPending = path.join(root, "runtime", ".restart-pending"); + yield* fs.writeFileString(restartPending, `${activeVersion}\n`); + + yield* Effect.promise(() => + expect(runLauncher(root)).rejects.toThrow("Active child exited unexpectedly (0)."), + ); + + assert.isTrue(yield* fs.exists(path.join(versionDir, "started"))); + assert.isFalse(yield* fs.exists(restartPending)); + assert.equal(yield* fs.readFileString(statePath), legacyContents); + }), + ); + + it.effect("refuses non-install legacy documents without rewriting or spawning", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-service-launcher-reject-" }); + const statePath = path.join(root, "runtime", "service-state.json"); + yield* writeFakeRuntime( + fs, + path, + path.join(root, "runtime", "versions", "1.0.0"), + "process.exit(0);\n", + ); + const legacy = { protocol: 2, activeVersion: "1.0.0" }; + const childCount = spawnedChildren.length; + for (const contents of [ + JSON.stringify({ + ...legacy, + update: { + id: "u", + fromVersion: "1.0.0", + targetVersion: "1.1.0", + dbPath: "/tmp/state.sqlite", + status: "pending", + }, + }), + JSON.stringify({ ...legacy, update: null }), + JSON.stringify({ ...legacy, extra: true }), + JSON.stringify({ ...legacy, protocol: 1 }), + JSON.stringify({ ...legacy, protocol: 5 }), + JSON.stringify({ ...legacy, activeVersion: "latest" }), + "{ invalid JSON", + ]) { + yield* fs.writeFileString(statePath, contents); + yield* Effect.promise(() => + expect(runLauncher(root)).rejects.toThrow("Service state is invalid or unsupported."), + ); + assert.equal(yield* fs.readFileString(statePath), contents); + assert.equal(spawnedChildren.length, childCount); + } + }), + ); + + it.effect("refuses a legacy state whose runtime is missing without rewriting or spawning", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-service-launcher-layout-" }); + const statePath = path.join(root, "runtime", "service-state.json"); + const contents = '{ "protocol": 2, "activeVersion": "1.0.0" }'; + yield* fs.makeDirectory(path.dirname(statePath), { recursive: true }); + yield* fs.writeFileString(statePath, contents); + const childCount = spawnedChildren.length; + + yield* Effect.promise(() => + expect(runLauncher(root)).rejects.toThrow( + "Selected t3@1.0.0 runtime is missing or incomplete.", + ), + ); + assert.equal(yield* fs.readFileString(statePath), contents); + assert.equal(spawnedChildren.length, childCount); + }), + ); + it.effect("a fresh launcher clears a restart deferred by t3 update", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; diff --git a/apps/server/src/serviceLauncher.ts b/apps/server/src/serviceLauncher.ts index e6d3431f39b3b..fb847dd1edaa0 100644 --- a/apps/server/src/serviceLauncher.ts +++ b/apps/server/src/serviceLauncher.ts @@ -21,6 +21,7 @@ import { compareExactServiceVersions, decodeServiceLauncherChildMessage, isExactServiceVersion, + parseLegacyServiceState, parseServiceState, serviceUnitBaseDirOf, SERVICE_ENV_FILE, @@ -514,11 +515,19 @@ async function discardDatabaseBackup(baseDir: string, updateId: string): Promise export async function readServiceState(filePath: string): Promise { const contents = await NodeFSP.readFile(filePath, "utf8"); - const state = parseServiceState(contents); + const state = parseServiceState(contents) ?? adoptLegacyServiceState(contents); if (state === undefined) throw new Error("Service state is invalid or unsupported."); return state; } +// An older coordinator can write protocol 2 beside a newer runtime. Adopt +// only its install record, in memory: rewriting it outside the selection lock +// could replace state a concurrent install wrote after this read. +function adoptLegacyServiceState(contents: string): ServiceState | undefined { + const legacy = parseLegacyServiceState(contents); + return legacy && { protocol: SERVICE_LAUNCHER_PROTOCOL, activeVersion: legacy.activeVersion }; +} + export async function writeServiceState(filePath: string, state: ServiceState): Promise { await NodeFSP.mkdir(NodePath.dirname(filePath), { recursive: true, mode: 0o700 }); await replaceFileDurably(filePath, `${JSON.stringify(state, null, 2)}\n`); @@ -1106,7 +1115,15 @@ export async function main(): Promise { } const environment = await readServiceEnvironment(baseDir); const statePath = NodePath.join(baseDir, "runtime", SERVICE_STATE_FILE); - const state = await readServiceState(statePath); + const contents = await NodeFSP.readFile(statePath, "utf8"); + let state = parseServiceState(contents); + if (state === undefined) { + state = adoptLegacyServiceState(contents); + if (state === undefined) throw new Error("Service state is invalid or unsupported."); + if (!(await runtimeExists(baseDir, state.activeVersion))) { + throw new Error(`Selected t3@${state.activeVersion} runtime is missing or incomplete.`); + } + } const serviceUnit = serviceUnitFromEnvironment(); await new Launcher( baseDir,