From 1323683d7213f5cf91038665356ff0c1d2d2e6d6 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 26 Apr 2026 09:13:18 +0100 Subject: [PATCH] fix: stabilize qa lab capture store cleanup --- extensions/qa-lab/src/lab-server.test.ts | 4 +++ extensions/qa-lab/src/lab-server.ts | 10 +++++-- src/plugin-sdk/proxy-capture.ts | 2 ++ src/proxy-capture/store.sqlite.test.ts | 38 +++++++++++++++++++++++- src/proxy-capture/store.sqlite.ts | 37 ++++++++++++++++++++++- 5 files changed, 86 insertions(+), 5 deletions(-) diff --git a/extensions/qa-lab/src/lab-server.test.ts b/extensions/qa-lab/src/lab-server.test.ts index 9160df70eea..038da1da940 100644 --- a/extensions/qa-lab/src/lab-server.test.ts +++ b/extensions/qa-lab/src/lab-server.test.ts @@ -113,6 +113,10 @@ const captureMock = vi.hoisted(() => { }); vi.mock("openclaw/plugin-sdk/proxy-capture", () => ({ + acquireDebugProxyCaptureStore: () => ({ + store: captureMock.store, + release: captureMock.store.close, + }), getDebugProxyCaptureStore: () => captureMock.store, resolveDebugProxySettings: () => ({ dbPath: process.env.OPENCLAW_DEBUG_PROXY_DB_PATH ?? "", diff --git a/extensions/qa-lab/src/lab-server.ts b/extensions/qa-lab/src/lab-server.ts index 11d544a238f..cca29c3edc4 100644 --- a/extensions/qa-lab/src/lab-server.ts +++ b/extensions/qa-lab/src/lab-server.ts @@ -3,7 +3,7 @@ import { createServer, type IncomingMessage } from "node:http"; import path from "node:path"; import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime"; import { - getDebugProxyCaptureStore, + acquireDebugProxyCaptureStore, resolveDebugProxySettings, } from "openclaw/plugin-sdk/proxy-capture"; import { closeQaHttpServer, handleQaBusRequest, writeError, writeJson } from "./bus-server.js"; @@ -168,7 +168,11 @@ export async function startQaLabServer( ): Promise { const repoRoot = path.resolve(params?.repoRoot ?? process.cwd()); const captureSettings = resolveDebugProxySettings(); - const captureStore = getDebugProxyCaptureStore(captureSettings.dbPath, captureSettings.blobDir); + const captureStoreLease = acquireDebugProxyCaptureStore( + captureSettings.dbPath, + captureSettings.blobDir, + ); + const captureStore = captureStoreLease.store; const state = createQaBusState(); let latestReport: QaLabLatestReport | null = null; let latestScenarioRun: QaLabScenarioRun | null = null; @@ -639,7 +643,7 @@ export async function startQaLabServer( await runnerModelCatalogPromise?.catch(() => undefined); await gateway?.stop(); await closeQaHttpServer(server); - captureStore.close(); + captureStoreLease.release(); }, }; labHandle = lab; diff --git a/src/plugin-sdk/proxy-capture.ts b/src/plugin-sdk/proxy-capture.ts index f653c85964a..f2f2dc2bde5 100644 --- a/src/plugin-sdk/proxy-capture.ts +++ b/src/plugin-sdk/proxy-capture.ts @@ -4,7 +4,9 @@ export { resolveEffectiveDebugProxyUrl, } from "../proxy-capture/env.js"; export { + acquireDebugProxyCaptureStore, DebugProxyCaptureStore, + closeDebugProxyCaptureStore, getDebugProxyCaptureStore, } from "../proxy-capture/store.sqlite.js"; export { diff --git a/src/proxy-capture/store.sqlite.test.ts b/src/proxy-capture/store.sqlite.test.ts index 2a602b0b65e..84e1017ecc6 100644 --- a/src/proxy-capture/store.sqlite.test.ts +++ b/src/proxy-capture/store.sqlite.test.ts @@ -2,11 +2,18 @@ import { mkdtempSync, rmSync } from "node:fs"; import os from "node:os"; import path from "node:path"; import { afterEach, describe, expect, it } from "vitest"; -import { DebugProxyCaptureStore, persistEventPayload } from "./store.sqlite.js"; +import { + acquireDebugProxyCaptureStore, + closeDebugProxyCaptureStore, + DebugProxyCaptureStore, + getDebugProxyCaptureStore, + persistEventPayload, +} from "./store.sqlite.js"; const cleanupDirs: string[] = []; afterEach(() => { + closeDebugProxyCaptureStore(); while (cleanupDirs.length > 0) { const dir = cleanupDirs.pop(); if (dir) { @@ -22,6 +29,35 @@ function makeStore() { } describe("DebugProxyCaptureStore", () => { + it("keeps the cached store open until the last lease releases", () => { + const root = mkdtempSync(path.join(os.tmpdir(), "openclaw-proxy-capture-lease-")); + cleanupDirs.push(root); + const dbPath = path.join(root, "capture.sqlite"); + const blobDir = path.join(root, "blobs"); + + const first = acquireDebugProxyCaptureStore(dbPath, blobDir); + const second = acquireDebugProxyCaptureStore(dbPath, blobDir); + + expect(second.store).toBe(first.store); + first.release(); + expect(first.store.isClosed).toBe(false); + + second.release(); + expect(first.store.isClosed).toBe(true); + + const reopened = getDebugProxyCaptureStore(dbPath, blobDir); + expect(Object.is(reopened, first.store)).toBe(false); + expect(reopened.isClosed).toBe(false); + }); + + it("ignores duplicate close calls", () => { + const store = makeStore(); + + store.close(); + expect(() => store.close()).not.toThrow(); + expect(store.isClosed).toBe(true); + }); + it("stores sessions, blobs, and duplicate-send query results", () => { const store = makeStore(); store.upsertSession({ diff --git a/src/proxy-capture/store.sqlite.ts b/src/proxy-capture/store.sqlite.ts index 535186ca16c..d0e433de516 100644 --- a/src/proxy-capture/store.sqlite.ts +++ b/src/proxy-capture/store.sqlite.ts @@ -93,6 +93,7 @@ function sortObservedCounts(counts: Map): CaptureObservedDimensi export class DebugProxyCaptureStore { readonly db: DatabaseSync; + private closed = false; constructor( readonly dbPath: string, @@ -102,7 +103,15 @@ export class DebugProxyCaptureStore { } close(): void { + if (this.closed) { + return; + } this.db.close(); + this.closed = true; + } + + get isClosed(): boolean { + return this.closed; } upsertSession(session: CaptureSessionRecord): void { @@ -448,12 +457,14 @@ export class DebugProxyCaptureStore { let cachedStore: DebugProxyCaptureStore | null = null; let cachedKey = ""; +let cachedStoreLeases = 0; export function getDebugProxyCaptureStore(dbPath: string, blobDir: string): DebugProxyCaptureStore { const key = `${dbPath}:${blobDir}`; - if (!cachedStore || cachedKey !== key) { + if (!cachedStore || cachedStore.isClosed || cachedKey !== key) { cachedStore = new DebugProxyCaptureStore(dbPath, blobDir); cachedKey = key; + cachedStoreLeases = 0; } return cachedStore; } @@ -465,6 +476,30 @@ export function closeDebugProxyCaptureStore(): void { cachedStore.close(); cachedStore = null; cachedKey = ""; + cachedStoreLeases = 0; +} + +export function acquireDebugProxyCaptureStore( + dbPath: string, + blobDir: string, +): { store: DebugProxyCaptureStore; release: () => void } { + const store = getDebugProxyCaptureStore(dbPath, blobDir); + const key = cachedKey; + cachedStoreLeases += 1; + let released = false; + return { + store, + release: () => { + if (released) { + return; + } + released = true; + cachedStoreLeases = Math.max(0, cachedStoreLeases - 1); + if (cachedStoreLeases === 0 && cachedStore === store && cachedKey === key) { + closeDebugProxyCaptureStore(); + } + }, + }; } export function persistEventPayload(