diff --git a/.changeset/central-core-cli-backend-init.md b/.changeset/central-core-cli-backend-init.md new file mode 100644 index 0000000000..3bbc02135f --- /dev/null +++ b/.changeset/central-core-cli-backend-init.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Node, mesh, and project CLI commands restore registry access without marking the host offline on exit. +category: fix +dev: Restore the existing layer-less `CentralCore.init()` backend bootstrap that PostgreSQL dual-path cleanup accidentally left unreachable. Generic `close()` only releases resources; daemon, engine-manager, and dashboard shutdown owners retain their explicitly ordered `markLocalNodeOffline()` writes. diff --git a/packages/core/src/__tests__/backup.test.ts b/packages/core/src/__tests__/backup.test.ts index 9692e965a1..1a1f9caa55 100644 --- a/packages/core/src/__tests__/backup.test.ts +++ b/packages/core/src/__tests__/backup.test.ts @@ -6,6 +6,7 @@ import { } from "../backup.js"; import { clearActiveEmbeddedRuntimeUrl, + EmbeddedRuntimeStoppingError, getActiveEmbeddedRuntimeUrl, invalidateEmbeddedRuntimeUrl, registerEmbeddedRuntimeUrl, @@ -45,14 +46,76 @@ describe("embedded backup runtime URL registry", () => { ); }); - it("keeps an owner URL live when only a joiner releases", () => { + it("keeps an owner URL live when only a joiner releases", async () => { const owner = registerEmbeddedRuntimeUrl(embeddedUrl, { ownsProcess: true }); const joiner = registerEmbeddedRuntimeUrl(embeddedUrl, { ownsProcess: false }); - releaseEmbeddedRuntimeLease(joiner); + await releaseEmbeddedRuntimeLease(joiner); expect(getActiveEmbeddedRuntimeUrl()).toBe(embeddedUrl); - releaseEmbeddedRuntimeLease(owner); + await releaseEmbeddedRuntimeLease(owner); + expect(getActiveEmbeddedRuntimeUrl()).toBeUndefined(); + }); + + it("defers owner shutdown until the final joined lease releases", async () => { + const stopOwner = vi.fn(async () => undefined); + const owner = registerEmbeddedRuntimeUrl(embeddedUrl, { ownsProcess: true }); + const joiner = registerEmbeddedRuntimeUrl(embeddedUrl, { ownsProcess: false }); + + await releaseEmbeddedRuntimeLease(owner, { stopOwner }); + expect(stopOwner).not.toHaveBeenCalled(); + expect(getActiveEmbeddedRuntimeUrl()).toBe(embeddedUrl); + + await releaseEmbeddedRuntimeLease(joiner); + expect(stopOwner).toHaveBeenCalledOnce(); + expect(getActiveEmbeddedRuntimeUrl()).toBeUndefined(); + }); + + it("rejects registrations until a deferred owner stop completes", async () => { + const owner = registerEmbeddedRuntimeUrl(embeddedUrl, { ownsProcess: true }); + const joiner = registerEmbeddedRuntimeUrl(embeddedUrl, { ownsProcess: false }); + let finishStop!: () => void; + const stopFinished = new Promise((resolve) => { + finishStop = resolve; + }); + const stopOwner = vi.fn(async () => stopFinished); + await releaseEmbeddedRuntimeLease(owner, { stopOwner }); + + const finalRelease = releaseEmbeddedRuntimeLease(joiner); + await vi.waitFor(() => expect(stopOwner).toHaveBeenCalledOnce()); + expect(getActiveEmbeddedRuntimeUrl()).toBeUndefined(); + let stoppingError: EmbeddedRuntimeStoppingError | undefined; + try { + registerEmbeddedRuntimeUrl(embeddedUrl, { ownsProcess: false }); + } catch (error) { + if (error instanceof EmbeddedRuntimeStoppingError) stoppingError = error; + } + expect(stoppingError).toBeInstanceOf(EmbeddedRuntimeStoppingError); + if (!stoppingError) throw new Error("Expected stopping registration to expose completion"); + const stopCompletion = stoppingError.completion; + let stopCompletionSettled = false; + void stopCompletion.then(() => { + stopCompletionSettled = true; + }); + await Promise.resolve(); + expect(stopCompletionSettled).toBe(false); + + finishStop(); + await stopCompletion; + await finalRelease; + expect(stopCompletionSettled).toBe(true); + expect(registerEmbeddedRuntimeUrl(embeddedUrl, { ownsProcess: true })).toBeDefined(); + }); + + it("stops the owner immediately when joined leases already released", async () => { + const stopOwner = vi.fn(async () => undefined); + const owner = registerEmbeddedRuntimeUrl(embeddedUrl, { ownsProcess: true }); + const joiner = registerEmbeddedRuntimeUrl(embeddedUrl, { ownsProcess: false }); + + await releaseEmbeddedRuntimeLease(joiner); + await releaseEmbeddedRuntimeLease(owner, { stopOwner }); + + expect(stopOwner).toHaveBeenCalledOnce(); expect(getActiveEmbeddedRuntimeUrl()).toBeUndefined(); }); diff --git a/packages/core/src/__tests__/central-core-layerless-init.test.ts b/packages/core/src/__tests__/central-core-layerless-init.test.ts new file mode 100644 index 0000000000..99fccce490 --- /dev/null +++ b/packages/core/src/__tests__/central-core-layerless-init.test.ts @@ -0,0 +1,171 @@ +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + createCentralBackendLayer: vi.fn(), + ensureBackendBootstrap: vi.fn(), + getLocalNode: vi.fn(), + releaseConnections: vi.fn(), + shutdown: vi.fn(), +})); + +vi.mock("../postgres/startup-factory.js", () => ({ + createCentralBackendLayer: mocks.createCentralBackendLayer, +})); + +vi.mock("../async-central-core.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + ensureBackendBootstrap: mocks.ensureBackendBootstrap, + getLocalNode: mocks.getLocalNode, + }; +}); + +import { CentralCore } from "../central-core.js"; + +const cleanupDirs: string[] = []; +const ownedLayer = { db: {} }; + +describe("CentralCore layer-less initialization", () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.createCentralBackendLayer.mockResolvedValue({ + asyncLayer: ownedLayer, + releaseConnections: mocks.releaseConnections, + shutdown: mocks.shutdown, + }); + mocks.getLocalNode.mockResolvedValue(undefined); + }); + + afterEach(() => { + for (const dir of cleanupDirs.splice(0)) { + rmSync(dir, { recursive: true, force: true }); + } + }); + + /** + * FNXC:CentralCore 2026-07-29-16:10: + * Standalone callers must bootstrap the exact layer they own and release that lifecycle on close; concurrent init calls share the same allocation. + */ + it("boots and owns a PostgreSQL layer for standalone CLI callers", async () => { + const globalDir = mkdtempSync(join(tmpdir(), "fusion-central-cli-init-")); + cleanupDirs.push(globalDir); + const central = new CentralCore(globalDir); + + await central.init(); + + expect(mocks.createCentralBackendLayer).toHaveBeenCalledWith({ globalSettingsDir: globalDir }); + expect(mocks.ensureBackendBootstrap).toHaveBeenCalledWith(ownedLayer); + expect(central.asyncLayer).toBe(ownedLayer); + expect(central.backendMode).toBe(true); + + await central.close(); + + expect(mocks.getLocalNode).not.toHaveBeenCalled(); + expect(mocks.shutdown).toHaveBeenCalledOnce(); + }); + + it("coalesces concurrent layer-less initialization into one owned backend", async () => { + const globalDir = mkdtempSync(join(tmpdir(), "fusion-central-cli-concurrent-init-")); + cleanupDirs.push(globalDir); + const central = new CentralCore(globalDir); + + await Promise.all([central.init(), central.init()]); + + expect(mocks.createCentralBackendLayer).toHaveBeenCalledOnce(); + expect(mocks.ensureBackendBootstrap).toHaveBeenCalledOnce(); + expect(mocks.ensureBackendBootstrap).toHaveBeenCalledWith(ownedLayer); + + await central.close(); + expect(mocks.shutdown).toHaveBeenCalledOnce(); + }); + + it("waits for in-flight initialization before closing its owned backend", async () => { + const globalDir = mkdtempSync(join(tmpdir(), "fusion-central-cli-close-race-")); + cleanupDirs.push(globalDir); + const central = new CentralCore(globalDir); + let finishBackend!: (value: { + asyncLayer: typeof ownedLayer; + releaseConnections: typeof mocks.releaseConnections; + shutdown: typeof mocks.shutdown; + }) => void; + mocks.createCentralBackendLayer.mockReturnValueOnce(new Promise((resolve) => { + finishBackend = resolve; + })); + + const initialization = central.init(); + await vi.waitFor(() => expect(mocks.createCentralBackendLayer).toHaveBeenCalledOnce()); + const closing = central.close(); + expect(mocks.shutdown).not.toHaveBeenCalled(); + finishBackend({ + asyncLayer: ownedLayer, + releaseConnections: mocks.releaseConnections, + shutdown: mocks.shutdown, + }); + + await Promise.all([initialization, closing]); + expect(mocks.shutdown).toHaveBeenCalledOnce(); + expect(central.backendMode).toBe(false); + }); + + it("serializes close behind an in-flight layer attachment", async () => { + const globalDir = mkdtempSync(join(tmpdir(), "fusion-central-cli-attach-close-race-")); + cleanupDirs.push(globalDir); + const central = new CentralCore(globalDir); + await central.init(); + const sharedLayer = { db: { shared: true } }; + let finishAttachment!: () => void; + mocks.ensureBackendBootstrap.mockImplementationOnce( + () => new Promise((resolve) => { + finishAttachment = resolve; + }), + ); + + const attachment = central.attachBackendLayer( + sharedLayer as unknown as Parameters[0], + ); + await vi.waitFor(() => expect(mocks.ensureBackendBootstrap).toHaveBeenCalledTimes(2)); + const closing = central.close(); + expect(mocks.shutdown).not.toHaveBeenCalled(); + finishAttachment(); + + await Promise.all([attachment, closing]); + expect(mocks.shutdown).toHaveBeenCalledOnce(); + expect(central.backendMode).toBe(false); + }); + + /** + * FNXC:CentralPostgresCutover 2026-07-29-17:43: + * Closing CentralCore is terminal because cleanup removes listeners and releases owned resources. Later queued initialization or attachment must not revive a partially torn-down instance. + */ + it("rejects initialization and layer attachment once close is requested", async () => { + const globalDir = mkdtempSync(join(tmpdir(), "fusion-central-cli-terminal-close-")); + cleanupDirs.push(globalDir); + const central = new CentralCore(globalDir); + await central.init(); + let finishShutdown!: () => void; + mocks.shutdown.mockImplementationOnce(() => new Promise((resolve) => { + finishShutdown = resolve; + })); + + const closing = central.close(); + const sharedLayer = { db: { shared: true } }; + const initializationAfterClose = central.init(); + const attachmentAfterClose = central.attachBackendLayer( + sharedLayer as unknown as Parameters[0], + ); + + await expect(initializationAfterClose).rejects.toThrow("CentralCore is closed"); + await expect(attachmentAfterClose).rejects.toThrow("CentralCore is closed"); + await vi.waitFor(() => expect(mocks.shutdown).toHaveBeenCalledOnce()); + + finishShutdown(); + await closing; + + expect(mocks.createCentralBackendLayer).toHaveBeenCalledOnce(); + expect(central.backendMode).toBe(false); + }); +}); diff --git a/packages/core/src/central-core.ts b/packages/core/src/central-core.ts index 8864f9207e..b0ac793b90 100644 --- a/packages/core/src/central-core.ts +++ b/packages/core/src/central-core.ts @@ -215,6 +215,16 @@ export class CentralCore extends EventEmitter { private readonly ensureGitRepositoryForProjectPath: typeof ensureGitRepositoryForProjectPath; private ownedBackendShutdown: (() => Promise) | null = null; private ownedBackendReleaseConnections: (() => Promise) | null = null; + private initializationPromise: Promise | null = null; + private lifecycleOperation: Promise = Promise.resolve(); + private closeRequested = false; + private closed = false; + + private runLifecycleOperation(operation: () => Promise): Promise { + const result = this.lifecycleOperation.then(operation, operation); + this.lifecycleOperation = result.then(() => undefined, () => undefined); + return result; + } /** * FNXC:CentralCore 2026-06-26-12:30: @@ -242,9 +252,15 @@ export class CentralCore extends EventEmitter { * call, backendMode is true and all methods delegate to PostgreSQL. */ async attachBackendLayer(layer: AsyncDataLayer): Promise { + this.assertAcceptingOperations(); if (!layer) { throw new Error("attachBackendLayer requires a non-null AsyncDataLayer"); } + return this.runLifecycleOperation(() => this.attachBackendLayerOnce(layer)); + } + + private async attachBackendLayerOnce(layer: AsyncDataLayer): Promise { + this.assertOpen(); // Release a central-only pool before adopting the runtime's shared layer. if (this.ownedBackendReleaseConnections) { /* @@ -270,7 +286,7 @@ export class CentralCore extends EventEmitter { // post-construction injection point. (this as { asyncLayer: AsyncDataLayer | null }).asyncLayer = layer; this.initialized = false; - await this.init(); + await this.initializeOnce(); } private readonly onDiscoveryNodeDiscovered = (node: DiscoveredNode): void => { @@ -318,19 +334,33 @@ export class CentralCore extends EventEmitter { * Idempotent — safe to call multiple times. */ async init(): Promise { + this.assertAcceptingOperations(); if (this.initialized) return; /* - FNXC:SqliteDualPathCleanup 2026-07-26-14:15: - CentralCore.init is PostgreSQL-only. When no asyncLayer is attached yet, mark initialized without opening SQLite; attachBackendLayer bootstraps PG later. - */ + * FNXC:CentralCore 2026-07-29-16:10: + * Layer-less initialization allocates an owned PostgreSQL lifecycle. Concurrent callers must share one in-flight attempt so a second backend cannot be orphaned when ownership fields are overwritten. Clear the promise after either outcome so a failed bootstrap remains retryable. + */ + if (!this.initializationPromise) { + this.initializationPromise = this.runLifecycleOperation(() => this.initializeOnce()); + } + const initialization = this.initializationPromise; + try { + await initialization; + } finally { + if (this.initializationPromise === initialization) this.initializationPromise = null; + } + } + + private async initializeOnce(): Promise { + this.assertOpen(); + if (this.initialized) return; + if (this.asyncLayer) { await asyncCentralCore.ensureBackendBootstrap(this.asyncLayer); this.initialized = true; return; } - this.initialized = true; - return; /* * FNXC:CentralPostgresCutover 2026-07-14-17:14: @@ -364,14 +394,23 @@ export class CentralCore extends EventEmitter { * Closes database connections and releases resources. */ async close(): Promise { + this.closeRequested = true; + return this.runLifecycleOperation(() => this.closeOnce()); + } + + private async closeOnce(): Promise { + /* + FNXC:CentralPostgresCutover 2026-07-29-16:26: + Initialization, layer replacement, and close share one lifecycle queue. Cleanup must observe and release the backend the preceding operation publishes instead of returning early, leaking it, or letting attachment revive a core after shutdown. + + FNXC:CentralPostgresCutover 2026-07-29-17:43: + Close is terminal. Operations queued after cleanup must fail instead of allocating or attaching a backend after listeners and owned resources have been released. + */ + this.closed = true; if (this.nodeDiscovery) { this.stopDiscovery(); } - await this.markLocalNodeOffline().catch((error) => { - severityAuditLog.warn("[central-core] Failed to persist local node offline during close", error); - }); - // FNXC:CentralCore 2026-06-26-12:30: In backend mode there is no SQLite // CentralDatabase to close; the shared connection pool is owned by the // TaskStore/startup factory. CentralCore does not close the pool. @@ -389,6 +428,14 @@ export class CentralCore extends EventEmitter { this.removeAllListeners(); } + private assertOpen(): void { + if (this.closed) throw new Error("CentralCore is closed"); + } + + private assertAcceptingOperations(): void { + if (this.closeRequested) throw new Error("CentralCore is closed"); + } + /** Persist the local mesh node's terminal state before its backend closes. */ async markLocalNodeOffline(): Promise { if (!this.initialized) return; diff --git a/packages/core/src/postgres/active-backend-registry.ts b/packages/core/src/postgres/active-backend-registry.ts index b3b0ad1eab..bdc7678a3c 100644 --- a/packages/core/src/postgres/active-backend-registry.ts +++ b/packages/core/src/postgres/active-backend-registry.ts @@ -4,8 +4,8 @@ * while backup construction resolves synchronously. This process-local registry * bridges that gap without logging credentials. Leases represent individual * lifecycles within a physical cluster generation: a joiner's release cannot - * clear a newer generation, and owner shutdown invalidates every lease because - * it is the only lifecycle that actually stops the postmaster. + * clear a newer generation, and owner shutdown waits for every live lease + * because physical process ownership is not exclusive logical usage. */ /** Opaque handle for one embedded-backend lifecycle registration. */ @@ -20,6 +20,19 @@ interface Generation { readonly id: number; readonly leases: Set; latestRegistration: number; + pendingOwnerStop: (() => Promise) | null; + stopCompletion: Promise | null; + stopping: boolean; +} + +export class EmbeddedRuntimeStoppingError extends Error { + constructor( + readonly url: string, + readonly completion: Promise, + ) { + super("Embedded PostgreSQL runtime is stopping"); + this.name = "EmbeddedRuntimeStoppingError"; + } } interface LeaseMetadata { @@ -41,12 +54,27 @@ export function registerEmbeddedRuntimeUrl( options: { ownsProcess: boolean }, ): EmbeddedRuntimeLease { let generation = generationsByUrl.get(url); + if (generation?.stopping) { + if (!generation.stopCompletion) { + throw new Error("Embedded PostgreSQL runtime stop completion is missing"); + } + throw new EmbeddedRuntimeStoppingError(url, generation.stopCompletion); + } // FNXC:PostgresBackup 2026-07-16-12:40: An owner started a new postmaster, // so URL reuse must create a new generation rather than retain stale leases. if (!generation || options.ownsProcess) { const id = (nextGenerationByUrl.get(url) ?? 0) + 1; nextGenerationByUrl.set(url, id); - generation = { url, epoch: registryEpoch, id, leases: new Set(), latestRegistration: 0 }; + generation = { + url, + epoch: registryEpoch, + id, + leases: new Set(), + latestRegistration: 0, + pendingOwnerStop: null, + stopCompletion: null, + stopping: false, + }; generationsByUrl.set(url, generation); } @@ -62,8 +90,16 @@ export function registerEmbeddedRuntimeUrl( return lease; } -/** Release exactly one lifecycle lease; stale generation handles are inert. */ -export function releaseEmbeddedRuntimeLease(lease: EmbeddedRuntimeLease): void { +/** + * Release exactly one lifecycle lease; stale generation handles are inert. + * + * FNXC:PostgresResourceLifecycle 2026-07-29-16:10: + * An embedded-process owner may close before joined consumers. Record its stop callback and run it only after the final lease releases so short-lived central/CLI cleanup cannot terminate PostgreSQL beneath another live store. + */ +export async function releaseEmbeddedRuntimeLease( + lease: EmbeddedRuntimeLease, + options: { stopOwner?: () => Promise } = {}, +): Promise { const metadata = leaseMetadata.get(lease); if (!metadata) return; const generation = generationsByUrl.get(metadata.url); @@ -74,8 +110,33 @@ export function releaseEmbeddedRuntimeLease(lease: EmbeddedRuntimeLease): void { ) return; generation.leases.delete(lease); + if (metadata.ownsProcess && options.stopOwner) { + generation.pendingOwnerStop = options.stopOwner; + } if (generation.leases.size === 0) { - generationsByUrl.delete(metadata.url); + const stopOwner = generation.pendingOwnerStop; + generation.pendingOwnerStop = null; + if (stopOwner) { + /* + FNXC:PostgresLifecycle 2026-07-29-16:26: + Keep the generation visible as stopping until the owner callback completes. A concurrent bootstrap must retry rather than join a postmaster that is already committed to termination. + + FNXC:PostgresLifecycle 2026-07-29-17:43: + Publish the actual stop completion to rejected registrants. Startup waits on lifecycle completion instead of exhausting a fixed retry window while an orderly shutdown is still in progress. + */ + generation.stopping = true; + const stopCompletion = Promise.resolve().then(stopOwner); + generation.stopCompletion = stopCompletion; + try { + await stopCompletion; + } finally { + if (generationsByUrl.get(metadata.url) === generation) { + generationsByUrl.delete(metadata.url); + } + } + } else { + generationsByUrl.delete(metadata.url); + } } } @@ -103,7 +164,7 @@ export function invalidateEmbeddedRuntimeUrl(url: string, lease?: EmbeddedRuntim export function getActiveEmbeddedRuntimeUrl(): string | undefined { let latest: Generation | undefined; for (const generation of generationsByUrl.values()) { - if (generation.leases.size > 0 && (!latest || generation.latestRegistration > latest.latestRegistration)) { + if (!generation.stopping && generation.leases.size > 0 && (!latest || generation.latestRegistration > latest.latestRegistration)) { latest = generation; } } diff --git a/packages/core/src/postgres/startup-factory.ts b/packages/core/src/postgres/startup-factory.ts index 1a593a6726..96f49f070d 100644 --- a/packages/core/src/postgres/startup-factory.ts +++ b/packages/core/src/postgres/startup-factory.ts @@ -58,7 +58,7 @@ import { applySchemaBaseline, MIGRATION_BOOKKEEPING_TABLE } from "./schema-appli import type { PostgresJsDatabase } from "drizzle-orm/postgres-js"; import { createAsyncDataLayer, type AsyncDataLayer } from "./data-layer.js"; import { - invalidateEmbeddedRuntimeUrl, + EmbeddedRuntimeStoppingError, registerEmbeddedRuntimeUrl, releaseEmbeddedRuntimeLease, type EmbeddedRuntimeLease, @@ -226,8 +226,8 @@ interface SchemaBackendBootResult { /** * FNXC:PostgresBackup 2026-07-16-12:40: - * stop() only stops a postmaster owned by this lifecycle. Consequently owner - * teardown burns the URL generation for every joiner, while joiner teardown + * stop() only stops a postmaster owned by this lifecycle. The runtime registry + * defers that owner stop until joined leases release, while joiner teardown * releases only its opaque lease. This keeps synchronous backup resolution * aligned with physical cluster liveness without logging the credential URL. */ @@ -237,16 +237,20 @@ async function stopEmbeddedRuntime( runtimeUrl: string | null, ownsProcess: boolean, ): Promise { + if (!lease || !runtimeUrl) { + await lifecycle?.stop(); + return; + } + if (ownsProcess) { + await releaseEmbeddedRuntimeLease(lease, { + stopOwner: async () => { await lifecycle?.stop(); }, + }); + return; + } try { await lifecycle?.stop(); } finally { - if (lease && runtimeUrl) { - if (ownsProcess) { - invalidateEmbeddedRuntimeUrl(runtimeUrl, lease); - } else { - releaseEmbeddedRuntimeLease(lease); - } - } + await releaseEmbeddedRuntimeLease(lease); } } @@ -382,6 +386,14 @@ async function bootSchemaBackend( try { return await bootSchemaBackendOnce(options, bypassProjectIsolation); } catch (error) { + if (error instanceof EmbeddedRuntimeStoppingError) { + /* + FNXC:PostgresLifecycle 2026-07-29-17:43: + A replacement backend waits for the shared registry's actual owner-stop completion. Fixed backoff budgets can expire during a valid slow shutdown and turn orderly handoff into startup failure. + */ + await error.completion; + continue; + } if ( error instanceof JoinedInstanceUnreachableError && joinedRetryAttempt < JOINED_INSTANCE_RETRY_DELAYS_MS.length @@ -470,6 +482,7 @@ async function bootSchemaBackendOnce( } } catch (error) { await embeddedLifecycle.stop().catch(() => undefined); + if (error instanceof EmbeddedRuntimeStoppingError) throw error; throw new Error( `startup-factory: failed to start embedded PostgreSQL: ${error instanceof Error ? error.message : String(error)}`, ); @@ -1371,7 +1384,7 @@ export async function createTaskStoreForBackend( if (shutdownEmbedded) { try { (shutdownEmbedded as unknown as { detachWithoutStop?: () => void }).detachWithoutStop?.(); - if (embeddedRuntimeLease) releaseEmbeddedRuntimeLease(embeddedRuntimeLease); + if (embeddedRuntimeLease) await releaseEmbeddedRuntimeLease(embeddedRuntimeLease); } catch (err) { log.warn(`startup-factory: embedded PostgreSQL detach failed: ${ err instanceof Error ? err.message : String(err)