import path from "node:path"; import { normalizeModuleId, type EvaluatedModuleNode } from "vite/module-runner"; import { vi } from "vitest"; const source = (name: string) => normalizeModuleId(path.resolve(import.meta.dirname, "..", name)); const agentSource = source("src/state/openclaw-agent-db-lifecycle.ts"); const agentKey = Symbol.for("openclaw.agentDatabaseLifecycle"); const brokerKey = Symbol.for("openclaw.sqliteWorkerBroker"); const resetKey = Symbol.for("openclaw.globalSingletonLifecycleResets"); const retainedCustodyKey = Symbol.for("openclaw.sqliteTestRetainedCustody"); // These owners retain module closures and each other's lifecycle callbacks. // Keep their native custody intact through drainage, then retire the whole generation. export const sqliteTestSingletonPublications: ReadonlyMap = new Map([ [ source("src/state/openclaw-state-worker-owner.ts"), Symbol.for("openclaw.sharedStateWorkerOwner"), ], [source("src/infra/sqlite-worker-store.ts"), brokerKey], [source("src/state/openclaw-state-db-cache.ts"), Symbol.for("openclaw.stateDatabaseLifecycle")], [ source("src/state/openclaw-state-db-snapshot-owner.ts"), Symbol.for("openclaw.stateSnapshotOwners"), ], [source("src/state/openclaw-state-read-worker.ts"), Symbol.for("openclaw.stateReadWorkers")], [source("src/gateway/session-group-catalog.ts"), Symbol.for("openclaw.sessionGroupCatalog")], [agentSource, agentKey], ]); type AgentLifecycleModule = Pick< typeof import("../src/state/openclaw-agent-db-lifecycle.js"), "agentDatabaseLifecycle" | "closeOpenClawAgentDatabasesAsync" >; type AgentOwner = AgentLifecycleModule["agentDatabaseLifecycle"]; const agentClosers = new WeakMap Promise>(); type SingletonReset = { lifecycle: "close-and-restart" | "close-only" | "plugin-registry"; reset: () => void | Promise; }; function retainedCustody() { const store = globalThis as typeof globalThis & { [retainedCustodyKey]?: { sqlite: boolean; failedResets: WeakSet }; }; return (store[retainedCustodyKey] ??= { sqlite: false, failedResets: new WeakSet() }); } export function hasRetainedSqliteTestCustody(): boolean { return retainedCustody().sqlite; } export function retainSqliteTestCustody(): void { // A non-main-thread broker refusal leaves its lease for the process-death stale-lease sweep. // Until retirement facts exist, later files must not retry the closer or retire its broker. retainedCustody().sqlite = true; } /** Settle independent owners before shared storage; a failure retains its dependent family. */ export async function drainSqliteTestSingletons( onError: (phase: string, error: unknown) => void, ): Promise { const resets = (globalThis as Record)[resetKey] as | Map | undefined; const { failedResets } = retainedCustody(); const entries = [...(resets ?? [])].filter( ([, reset]) => reset.lifecycle !== "plugin-registry" && !failedResets.has(reset), ); const sqliteKeys = new Set(sqliteTestSingletonPublications.values()); await Promise.all( entries .filter(([key]) => !sqliteKeys.has(key)) .map(async ([key, reset]) => { try { await reset.reset(); } catch (error) { failedResets.add(reset); onError(`singleton ${key.description}`, error); } }), ); // Native resources finish their accepted work before the shared broker closes. const closeOrder = (key: symbol) => Number(key === brokerKey); const sqlite = entries .filter(([key]) => sqliteKeys.has(key)) .toSorted(([left], [right]) => closeOrder(left) - closeOrder(right)); for (const [key, reset] of sqlite) { if (hasRetainedSqliteTestCustody()) { break; } try { await reset.reset(); } catch (error) { retainSqliteTestCustody(); onError(`singleton ${key.description}`, error); } } } /** Preserve the verified owner's closer before a test hook can reset its module exports. */ export function rememberSqliteTestAgentOwner( modules: Iterable, executions: ReadonlyMap, ): void { const owner = (globalThis as Record)[agentKey] as AgentOwner | undefined; if (!owner || agentClosers.has(owner)) { return; } for (const node of modules) { if (node.file !== agentSource) { continue; } const execution = executions.get(node.id.startsWith("mock:") ? node.id.slice(5) : node.id); const exports = node.exports as Partial | undefined; if ( execution && !execution.external && exports && exports.agentDatabaseLifecycle === owner && typeof exports.closeOpenClawAgentDatabasesAsync === "function" && !vi.isMockFunction(exports.closeOpenClawAgentDatabasesAsync) ) { agentClosers.set(owner, exports.closeOpenClawAgentDatabasesAsync); return; } } } /** Agent lease cleanup still needs its original shared-state owner and broker. */ export async function drainSqliteTestAgentOwner( modules: Iterable, executions: ReadonlyMap, testFiles: string, ): Promise { rememberSqliteTestAgentOwner(modules, executions); const globalStore = globalThis as Record; const owner = globalStore[agentKey] as AgentLifecycleModule["agentDatabaseLifecycle"] | undefined; const resources = globalStore[Symbol.for("openclaw.agentDatabaseAsyncResources")] as | { active: Set; closing: Map; selections: Set } | undefined; const custody = () => ({ databases: owner?.databases.size ?? 0, leases: owner?.leases.size ?? 0, pending: owner?.pending.size ?? 0, activePending: owner?.activePending.size ?? 0, retainedCloses: owner?.retainedCloses.size ?? 0, resources: resources?.active.size ?? 0, closing: resources?.closing.size ?? 0, selections: resources?.selections.size ?? 0, }); const hasCustody = () => Object.values(custody()).some((count) => count > 0); if (!hasCustody()) { return; } console.warn( `[sqlite-test-lifecycle] ${testFiles}: draining agent database custody ${JSON.stringify(custody())}`, ); const close = owner && agentClosers.get(owner); if (close) { await close(); } // A module reset can erase the real closer. Never load a replacement under the // file's mocks or abandon handles just to make the next file start cleanly. if (hasCustody()) { throw new Error( `SQLite test teardown cannot retire agent owners with unsettled database custody from ${testFiles}: ${JSON.stringify(custody())}`, ); } } /** * Wait for agent database closes a finished test scheduled without awaiting. The * synchronous test closer only schedules Worker retirement; left running, a lease release * overlaps the next test, and a Vitest thread cannot retire an escaped lease afterwards. * A failed close stays in its owner's custody (logged, retried by the file drain). */ export async function settleSqliteTestAgentCloses(): Promise { const resources = (globalThis as Record)[ Symbol.for("openclaw.agentDatabaseAsyncResources") ] as | { closing: Map | undefined>; selections: Map>; } | undefined; if (!resources) { return; } const joined = new Set>(); // A settled close can start dependent retirement; wait until nothing new is pending. while (true) { const pending = [...resources.closing.values(), ...resources.selections.values()].filter( (operation): operation is Promise => operation !== undefined && !joined.has(operation), ); if (pending.length === 0) { return; } for (const operation of pending) { joined.add(operation); } await Promise.allSettled(pending); } } /** Called only for an evaluated source generation, after successful owner drainage. */ export function retireSqliteTestSingleton(key: symbol, testFiles: string): void { const globalStore = globalThis as Record; const resets = globalStore[resetKey] as Map | undefined; if (key === agentKey) { const owner = globalStore[agentKey] as AgentOwner | undefined; if (owner) { agentClosers.delete(owner); } } if (Object.hasOwn(globalStore, key)) { console.warn(`[sqlite-test-lifecycle] ${testFiles}: retiring ${key.description}`); } Reflect.deleteProperty(globalStore, key); resets?.delete(key); }