From 91cdc0aaaed4f3c455a708f307e36488af8298cc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=88=B7=E4=B8=AA?= <453241564@qq.com> Date: Sun, 5 Apr 2026 20:33:38 +0800 Subject: [PATCH 1/5] fix(plugin): start web server before memory warmup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 背景 - opencode-mem 在插件初始化阶段会阻塞式执行 memory warmup。 - 当 warmup 较慢时,Web UI 4747 无法及时启动,外部访问会表现为端口迟迟不可用。 ## 变更 - 将插件初始化中的 warmup 改为后台触发,避免阻塞启动流程。 - 保持 web server 先启动,再异步进行 memory warmup。 - 当 memory tool 在未 ready 状态被调用时,先触发/重试后台 warmup,再返回 initializing。 - 新增回归测试,锁定启动顺序与初始化重试逻辑。 ## 验证 - 已执行:bun install - 已执行:bun test tests/web-server-startup-order.test.ts - 结果:2 pass / 0 fail。 ## 风险与回滚 - 主要风险:warmup 从阻塞式改为后台触发后,冷启动阶段 memory tool 可能短暂返回 initializing。 - 回滚方式:回退本提交即可恢复原有阻塞式启动逻辑。 ## 备注 - 严格保持 warmup-only 范围,不包含 scope/defaultScope 等其它改动。 --- src/index.ts | 33 +++++++++++++++-------- tests/web-server-startup-order.test.ts | 37 ++++++++++++++++++++++++++ 2 files changed, 59 insertions(+), 11 deletions(-) create mode 100644 tests/web-server-startup-order.test.ts diff --git a/src/index.ts b/src/index.ts index aefd558..1e31804 100644 --- a/src/index.ts +++ b/src/index.ts @@ -23,19 +23,27 @@ export const OpenCodeMemPlugin: Plugin = async (ctx: PluginInput) => { let webServer: WebServer | null = null; let idleTimeout: Timer | null = null; - if (!isConfigured()) { - } - const GLOBAL_PLUGIN_WARMUP_KEY = Symbol.for("opencode-mem.plugin.warmedup"); + const GLOBAL_PLUGIN_WARMUP_PROMISE_KEY = Symbol.for("opencode-mem.plugin.warmupPromise"); - if (!(globalThis as any)[GLOBAL_PLUGIN_WARMUP_KEY] && isConfigured()) { - try { - await memoryClient.warmup(); - (globalThis as any)[GLOBAL_PLUGIN_WARMUP_KEY] = true; - } catch (error) { - log("Plugin warmup failed", { error: String(error) }); - } - } + const startBackgroundWarmup = () => { + if (!isConfigured()) return; + + const globalState = globalThis as any; + if (globalState[GLOBAL_PLUGIN_WARMUP_KEY]) return; + if (globalState[GLOBAL_PLUGIN_WARMUP_PROMISE_KEY]) return; + + globalState[GLOBAL_PLUGIN_WARMUP_PROMISE_KEY] = (async () => { + try { + await memoryClient.warmup(); + globalState[GLOBAL_PLUGIN_WARMUP_KEY] = true; + } catch (error) { + log("Plugin warmup failed", { error: String(error) }); + } finally { + globalState[GLOBAL_PLUGIN_WARMUP_PROMISE_KEY] = null; + } + })(); + }; // Wire opencode state path and provider list — fire-and-forget to avoid blocking init // These calls can hang if opencode isn't fully bootstrapped yet @@ -127,6 +135,8 @@ export const OpenCodeMemPlugin: Plugin = async (ctx: PluginInput) => { }); } + startBackgroundWarmup(); + const shutdownHandler = async () => { try { if (webServer) { @@ -275,6 +285,7 @@ export const OpenCodeMemPlugin: Plugin = async (ctx: PluginInput) => { const needsWarmup = !(await memoryClient.isReady()); if (needsWarmup) { + startBackgroundWarmup(); return JSON.stringify({ success: false, error: "Memory system is initializing." }); } diff --git a/tests/web-server-startup-order.test.ts b/tests/web-server-startup-order.test.ts new file mode 100644 index 0000000..8ad7778 --- /dev/null +++ b/tests/web-server-startup-order.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from "bun:test"; +import { readFileSync } from "node:fs"; + +describe("web server startup order", () => { + it("starts the web server before background warmup and avoids blocking warmup", () => { + const source = readFileSync(new URL("../src/index.ts", import.meta.url), "utf-8"); + + expect(source.includes("await memoryClient.warmup()")).toBe(true); + expect( + source.includes( + "await memoryClient.warmup();\n globalState[GLOBAL_PLUGIN_WARMUP_KEY] = true;" + ) + ).toBe(true); + expect( + source.includes( + "await memoryClient.warmup();\n (globalThis as any)[GLOBAL_PLUGIN_WARMUP_KEY] = true;" + ) + ).toBe(false); + expect(source.includes("startBackgroundWarmup();")).toBe(true); + + const webServerIndex = source.indexOf("startWebServer({"); + const warmupIndex = source.indexOf("startBackgroundWarmup();"); + expect(webServerIndex).toBeGreaterThan(-1); + expect(warmupIndex).toBeGreaterThan(-1); + expect(webServerIndex).toBeLessThan(warmupIndex); + }); + + it("retries background warmup when memory tool is still initializing", () => { + const source = readFileSync(new URL("../src/index.ts", import.meta.url), "utf-8"); + + const toolGuard = source.indexOf("const needsWarmup = !(await memoryClient.isReady());"); + const retryCall = source.indexOf("startBackgroundWarmup();", toolGuard); + + expect(toolGuard).toBeGreaterThan(-1); + expect(retryCall).toBeGreaterThan(toolGuard); + }); +}); From 2083b2647866e6d5da808534642658b64f807262 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=88=B7=E4=B8=AA?= <453241564@qq.com> Date: Sun, 5 Apr 2026 22:34:13 +0800 Subject: [PATCH 2/5] fix(plugin): allow warmup retry after timeout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 背景 - PR review 指出 background warmup 如果卡住,会永久占住 in-flight promise,后续重试无法恢复。 - 同时测试对源码缩进和换行过于敏感,容易因为格式调整而产生误报。 ## 变更 - 为 background warmup 增加 60 秒超时,并在超时后清理插件层与 embedding 层的 in-flight 状态,允许后续重试。 - 使用独立状态容器管理 warmup promise,避免自引用导致的 TS2454。 - 放宽启动顺序测试断言,改为统一换行后做语义匹配与索引比较。 ## 验证 - 已执行:bun x tsc --noEmit - 已执行:bun test tests/web-server-startup-order.test.ts - 结果:类型检查通过,测试 2 pass / 0 fail。 ## 风险与回滚 - 主要风险:超时后当前实现只释放 initPromise,不会取消先前仍在执行的 embedding 初始化,因此极慢环境下仍可能出现一次超时后触发重复初始化,带来额外启动开销。 - 回滚方式:回退本提交即可恢复上一版 warmup-only 逻辑。 ## 备注 - 仍严格保持 warmup-only 范围,不包含 scope/defaultScope 等其它改动。 --- src/index.ts | 24 ++++++++++++++++++--- src/services/embedding.ts | 4 ++++ tests/web-server-startup-order.test.ts | 29 +++++++++++++------------- 3 files changed, 40 insertions(+), 17 deletions(-) diff --git a/src/index.ts b/src/index.ts index 1e31804..91e6b78 100644 --- a/src/index.ts +++ b/src/index.ts @@ -10,6 +10,7 @@ import { performAutoCapture } from "./services/auto-capture.js"; import { performUserProfileLearning } from "./services/user-memory-learning.js"; import { userPromptManager } from "./services/user-prompt/user-prompt-manager.js"; import { startWebServer, WebServer } from "./services/web-server.js"; +import { embeddingService } from "./services/embedding.js"; import { isConfigured, CONFIG, initConfig } from "./config.js"; import { log } from "./services/logger.js"; @@ -25,6 +26,7 @@ export const OpenCodeMemPlugin: Plugin = async (ctx: PluginInput) => { const GLOBAL_PLUGIN_WARMUP_KEY = Symbol.for("opencode-mem.plugin.warmedup"); const GLOBAL_PLUGIN_WARMUP_PROMISE_KEY = Symbol.for("opencode-mem.plugin.warmupPromise"); + const GLOBAL_PLUGIN_WARMUP_TIMEOUT_MS = 60_000; const startBackgroundWarmup = () => { if (!isConfigured()) return; @@ -33,16 +35,32 @@ export const OpenCodeMemPlugin: Plugin = async (ctx: PluginInput) => { if (globalState[GLOBAL_PLUGIN_WARMUP_KEY]) return; if (globalState[GLOBAL_PLUGIN_WARMUP_PROMISE_KEY]) return; - globalState[GLOBAL_PLUGIN_WARMUP_PROMISE_KEY] = (async () => { + const warmupState: { promise: Promise | null } = { promise: null }; + warmupState.promise = (async () => { try { - await memoryClient.warmup(); + await Promise.race([ + memoryClient.warmup(), + new Promise((_, reject) => { + setTimeout( + () => reject(new Error("Background warmup timed out")), + GLOBAL_PLUGIN_WARMUP_TIMEOUT_MS + ); + }), + ]); globalState[GLOBAL_PLUGIN_WARMUP_KEY] = true; } catch (error) { log("Plugin warmup failed", { error: String(error) }); + if (String(error).includes("Background warmup timed out")) { + embeddingService.resetWarmupState(); + } } finally { - globalState[GLOBAL_PLUGIN_WARMUP_PROMISE_KEY] = null; + if (globalState[GLOBAL_PLUGIN_WARMUP_PROMISE_KEY] === warmupState.promise) { + globalState[GLOBAL_PLUGIN_WARMUP_PROMISE_KEY] = null; + } } })(); + + globalState[GLOBAL_PLUGIN_WARMUP_PROMISE_KEY] = warmupState.promise; }; // Wire opencode state path and provider list — fire-and-forget to avoid blocking init diff --git a/src/services/embedding.ts b/src/services/embedding.ts index 128b9ce..cf55614 100644 --- a/src/services/embedding.ts +++ b/src/services/embedding.ts @@ -49,6 +49,10 @@ export class EmbeddingService { return this.initPromise; } + resetWarmupState(): void { + this.initPromise = null; + } + private async initializeModel(progressCallback?: (progress: any) => void): Promise { try { if (CONFIG.embeddingApiUrl && CONFIG.embeddingApiKey) { diff --git a/tests/web-server-startup-order.test.ts b/tests/web-server-startup-order.test.ts index 8ad7778..c84d0f3 100644 --- a/tests/web-server-startup-order.test.ts +++ b/tests/web-server-startup-order.test.ts @@ -3,20 +3,18 @@ import { readFileSync } from "node:fs"; describe("web server startup order", () => { it("starts the web server before background warmup and avoids blocking warmup", () => { - const source = readFileSync(new URL("../src/index.ts", import.meta.url), "utf-8"); + const source = readFileSync(new URL("../src/index.ts", import.meta.url), "utf-8").replace( + /\r\n/g, + "\n" + ); - expect(source.includes("await memoryClient.warmup()")).toBe(true); - expect( - source.includes( - "await memoryClient.warmup();\n globalState[GLOBAL_PLUGIN_WARMUP_KEY] = true;" - ) - ).toBe(true); - expect( - source.includes( - "await memoryClient.warmup();\n (globalThis as any)[GLOBAL_PLUGIN_WARMUP_KEY] = true;" - ) - ).toBe(false); - expect(source.includes("startBackgroundWarmup();")).toBe(true); + expect(source).toMatch(/memoryClient\.warmup\(\)/); + expect(source).toMatch(/Promise\.race\(\[\s*memoryClient\.warmup\(\)/s); + expect(source).toMatch(/GLOBAL_PLUGIN_WARMUP_TIMEOUT_MS/); + expect(source).toMatch( + /globalState\[GLOBAL_PLUGIN_WARMUP_PROMISE_KEY\] === warmupState\.promise/ + ); + expect(source).toMatch(/startBackgroundWarmup\(\);/); const webServerIndex = source.indexOf("startWebServer({"); const warmupIndex = source.indexOf("startBackgroundWarmup();"); @@ -26,7 +24,10 @@ describe("web server startup order", () => { }); it("retries background warmup when memory tool is still initializing", () => { - const source = readFileSync(new URL("../src/index.ts", import.meta.url), "utf-8"); + const source = readFileSync(new URL("../src/index.ts", import.meta.url), "utf-8").replace( + /\r\n/g, + "\n" + ); const toolGuard = source.indexOf("const needsWarmup = !(await memoryClient.isReady());"); const retryCall = source.indexOf("startBackgroundWarmup();", toolGuard); From 096ff8675c932062edf50bdf18ffb2e0576073a7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=88=B7=E4=B8=AA?= <453241564@qq.com> Date: Thu, 9 Apr 2026 22:52:54 +0800 Subject: [PATCH 3/5] fix(plugin): await web server startup before warmup ## Background - The reviewer noted that calling startWebServer(...) before warmup was not enough unless the plugin awaited the startup promise. - The previous regression test asserted source text order instead of runtime behavior. ## Changes - Await web server startup before launching background warmup so warmup only begins after the startup attempt finishes. - Replace the source-order test with runtime regression tests that mock the web server and memory client, verify server:ready happens before warmup:start, and keep the memory tool non-blocking while initialization is in progress. - Keep stale warmup retry coverage so a timed-out attempt does not block future retries. ## Validation - bun test tests/web-server-startup-order.test.ts - bun test tests/plugin-loader-contract.test.ts tests/web-server-startup-order.test.ts - bun run typecheck - bun run build fails in this environment because the existing script shells out to bunx tsc and bunx is unavailable; the equivalent build command bun x tsc && mkdir -p dist/web && cp -r src/web/* dist/web/ succeeds. --- src/index.ts | 111 ++++----- tests/web-server-startup-order.test.ts | 332 ++++++++++++++++++++++--- 2 files changed, 359 insertions(+), 84 deletions(-) diff --git a/src/index.ts b/src/index.ts index 91e6b78..9cf1284 100644 --- a/src/index.ts +++ b/src/index.ts @@ -83,74 +83,73 @@ export const OpenCodeMemPlugin: Plugin = async (ctx: PluginInput) => { })(); if (CONFIG.webServerEnabled) { - startWebServer({ - port: CONFIG.webServerPort, - host: CONFIG.webServerHost, - enabled: CONFIG.webServerEnabled, - }) - .then((server) => { - webServer = server; - const url = webServer.getUrl(); - - webServer.setOnTakeoverCallback(async () => { - if (ctx.client?.tui) { - ctx.client.tui - .showToast({ - body: { - title: "Memory Explorer", - message: "Took over web server ownership", - variant: "success", - duration: 3000, - }, - }) - .catch(() => {}); - } - }); + try { + webServer = await startWebServer({ + port: CONFIG.webServerPort, + host: CONFIG.webServerHost, + enabled: CONFIG.webServerEnabled, + }); - if (webServer.isServerOwner()) { - if (ctx.client?.tui) { - ctx.client.tui - .showToast({ - body: { - title: "Memory Explorer", - message: `Web UI started at ${url}`, - variant: "success", - duration: 5000, - }, - }) - .catch(() => {}); - } - } else { - if (ctx.client?.tui) { - ctx.client.tui - .showToast({ - body: { - title: "Memory Explorer", - message: `Web UI available at ${url}`, - variant: "info", - duration: 3000, - }, - }) - .catch(() => {}); - } + const url = webServer.getUrl(); + + webServer.setOnTakeoverCallback(async () => { + if (ctx.client?.tui) { + ctx.client.tui + .showToast({ + body: { + title: "Memory Explorer", + message: "Took over web server ownership", + variant: "success", + duration: 3000, + }, + }) + .catch(() => {}); } - }) - .catch((error) => { - log("Web server failed to start", { error: String(error) }); + }); + if (webServer.isServerOwner()) { if (ctx.client?.tui) { ctx.client.tui .showToast({ body: { - title: "Memory Explorer Error", - message: `Failed to start: ${String(error)}`, - variant: "error", + title: "Memory Explorer", + message: `Web UI started at ${url}`, + variant: "success", duration: 5000, }, }) .catch(() => {}); } - }); + } else { + if (ctx.client?.tui) { + ctx.client.tui + .showToast({ + body: { + title: "Memory Explorer", + message: `Web UI available at ${url}`, + variant: "info", + duration: 3000, + }, + }) + .catch(() => {}); + } + } + } catch (error) { + log("Web server failed to start", { error: String(error) }); + + if (ctx.client?.tui) { + ctx.client.tui + .showToast({ + body: { + title: "Memory Explorer Error", + message: `Failed to start: ${String(error)}`, + variant: "error", + duration: 5000, + }, + }) + .catch(() => {}); + } + } } startBackgroundWarmup(); diff --git a/tests/web-server-startup-order.test.ts b/tests/web-server-startup-order.test.ts index c84d0f3..b5a8e8b 100644 --- a/tests/web-server-startup-order.test.ts +++ b/tests/web-server-startup-order.test.ts @@ -1,38 +1,314 @@ -import { describe, expect, it } from "bun:test"; -import { readFileSync } from "node:fs"; +import { afterEach, beforeEach, describe, expect, it, mock, spyOn } from "bun:test"; + +const GLOBAL_PLUGIN_WARMUP_KEY = Symbol.for("opencode-mem.plugin.warmedup"); +const GLOBAL_PLUGIN_WARMUP_PROMISE_KEY = Symbol.for("opencode-mem.plugin.warmupPromise"); +const originalSetTimeout = globalThis.setTimeout; + +const CONFIG = { + webServerEnabled: true, + webServerPort: 3456, + webServerHost: "127.0.0.1", + autoCaptureLanguage: "en", + autoCaptureEnabled: false, + showErrorToasts: false, + chatMessage: { + enabled: false, + injectOn: "always", + maxMemories: 10, + excludeCurrentSession: false, + }, + compaction: { + enabled: false, + memoryLimit: 10, + }, +}; + +type TestState = { + configured: boolean; + isReady: boolean; + warmupCalls: number; + resetWarmupStateCalls: number; + events: string[]; + timeoutCallbacks: Array<() => void>; + startWebServerImpl: () => Promise; + warmupImpl: () => Promise; +}; + +type MockWebServer = { + getUrl: () => string; + isServerOwner: () => boolean; + setOnTakeoverCallback: (callback: () => Promise) => void; + stop: () => Promise; +}; + +const testState: TestState = { + configured: true, + isReady: false, + warmupCalls: 0, + resetWarmupStateCalls: 0, + events: [], + timeoutCallbacks: [], + startWebServerImpl: async () => createMockWebServer(), + warmupImpl: async () => {}, +}; + +function createMockWebServer(): MockWebServer { + return { + getUrl: () => `http://${CONFIG.webServerHost}:${CONFIG.webServerPort}`, + isServerOwner: () => true, + setOnTakeoverCallback: () => {}, + stop: async () => {}, + }; +} + +function resetWarmupGlobals() { + const globalState = globalThis as Record; + delete globalState[GLOBAL_PLUGIN_WARMUP_KEY]; + delete globalState[GLOBAL_PLUGIN_WARMUP_PROMISE_KEY]; +} + +function resetTestState() { + CONFIG.webServerEnabled = true; + CONFIG.webServerPort = 3456; + CONFIG.webServerHost = "127.0.0.1"; + + testState.configured = true; + testState.isReady = false; + testState.warmupCalls = 0; + testState.resetWarmupStateCalls = 0; + testState.events = []; + testState.timeoutCallbacks = []; + testState.startWebServerImpl = async () => { + testState.events.push("server:start", "server:ready"); + return createMockWebServer(); + }; + testState.warmupImpl = async () => {}; +} + +function createPluginInput() { + return { + directory: "/tmp/opencode-mem-runtime-test", + client: { + path: { + get: async () => ({ data: {} }), + }, + provider: { + list: async () => ({ data: {} }), + }, + session: { + messages: async () => ({ data: [] }), + prompt: async () => ({}), + }, + }, + } as any; +} + +async function flushMicrotasks() { + await Promise.resolve(); + await Promise.resolve(); +} + +mock.module("@opencode-ai/plugin", () => { + const optional = () => ({ optional }); + const toolFactory = (definition: Record) => definition; + + return { + tool: Object.assign(toolFactory, { + schema: { + enum: optional, + string: optional, + number: optional, + }, + }), + }; +}); + +mock.module("../src/config.js", () => ({ + CONFIG, + initConfig: () => {}, + isConfigured: () => testState.configured, +})); + +mock.module("../src/services/client.js", () => ({ + memoryClient: { + warmup: () => { + testState.warmupCalls += 1; + testState.events.push("warmup:start"); + return testState.warmupImpl(); + }, + isReady: async () => testState.isReady, + close: () => {}, + listMemories: async () => ({ success: true, memories: [] }), + addMemory: async () => ({ success: true, id: "mem-1" }), + searchMemories: async () => ({ success: true, results: [], total: 0, timing: 0 }), + deleteMemory: async () => ({ success: true }), + searchMemoriesBySessionID: async () => ({ success: true, results: [], total: 0, timing: 0 }), + }, +})); + +mock.module("../src/services/context.js", () => ({ + formatContextForPrompt: () => "", +})); + +mock.module("../src/services/tags.js", () => ({ + getTags: () => ({ + project: { + tag: "project_tag", + displayName: "Project", + userName: "User", + userEmail: "user@example.com", + projectPath: "/tmp/opencode-mem-runtime-test", + projectName: "opencode-mem-runtime-test", + gitRepoUrl: "https://github.com/tickernelz/opencode-mem", + }, + user: { + userEmail: "user@example.com", + }, + }), +})); + +mock.module("../src/services/privacy.js", () => ({ + stripPrivateContent: (content: string) => content, + isFullyPrivate: () => false, +})); + +mock.module("../src/services/auto-capture.js", () => ({ + performAutoCapture: async () => {}, +})); + +mock.module("../src/services/user-memory-learning.js", () => ({ + performUserProfileLearning: async () => {}, +})); + +mock.module("../src/services/user-prompt/user-prompt-manager.js", () => ({ + userPromptManager: { + savePrompt: () => {}, + }, +})); + +mock.module("../src/services/web-server.js", () => ({ + startWebServer: () => testState.startWebServerImpl(), +})); + +mock.module("../src/services/embedding.js", () => ({ + embeddingService: { + resetWarmupState: () => { + testState.resetWarmupStateCalls += 1; + }, + }, +})); + +mock.module("../src/services/logger.js", () => ({ + log: () => {}, +})); + +mock.module("../src/services/language-detector.js", () => ({ + getLanguageName: () => "English", +})); + +mock.module("../src/services/ai/opencode-provider.js", () => ({ + setStatePath: () => {}, + setConnectedProviders: () => {}, +})); + +const { OpenCodeMemPlugin } = await import("../src/index.ts"); describe("web server startup order", () => { - it("starts the web server before background warmup and avoids blocking warmup", () => { - const source = readFileSync(new URL("../src/index.ts", import.meta.url), "utf-8").replace( - /\r\n/g, - "\n" - ); + let processOnSpy: ReturnType; - expect(source).toMatch(/memoryClient\.warmup\(\)/); - expect(source).toMatch(/Promise\.race\(\[\s*memoryClient\.warmup\(\)/s); - expect(source).toMatch(/GLOBAL_PLUGIN_WARMUP_TIMEOUT_MS/); - expect(source).toMatch( - /globalState\[GLOBAL_PLUGIN_WARMUP_PROMISE_KEY\] === warmupState\.promise/ - ); - expect(source).toMatch(/startBackgroundWarmup\(\);/); + beforeEach(() => { + resetTestState(); + resetWarmupGlobals(); - const webServerIndex = source.indexOf("startWebServer({"); - const warmupIndex = source.indexOf("startBackgroundWarmup();"); - expect(webServerIndex).toBeGreaterThan(-1); - expect(warmupIndex).toBeGreaterThan(-1); - expect(webServerIndex).toBeLessThan(warmupIndex); + globalThis.setTimeout = ((handler: TimerHandler, _timeout?: number, ...args: unknown[]) => { + testState.timeoutCallbacks.push(() => { + if (typeof handler === "function") { + handler(...args); + } + }); + return 0 as any; + }) as typeof setTimeout; + + processOnSpy = spyOn(process, "on").mockImplementation(() => process as any); }); - it("retries background warmup when memory tool is still initializing", () => { - const source = readFileSync(new URL("../src/index.ts", import.meta.url), "utf-8").replace( - /\r\n/g, - "\n" - ); + afterEach(() => { + globalThis.setTimeout = originalSetTimeout; + processOnSpy.mockRestore(); + resetWarmupGlobals(); + }); - const toolGuard = source.indexOf("const needsWarmup = !(await memoryClient.isReady());"); - const retryCall = source.indexOf("startBackgroundWarmup();", toolGuard); + it("waits for web server startup to finish before background warmup begins", async () => { + let resolveServer!: (server: MockWebServer) => void; + + testState.startWebServerImpl = () => { + testState.events.push("server:start"); + return new Promise((resolve) => { + resolveServer = (server) => { + testState.events.push("server:ready"); + resolve(server); + }; + }); + }; + + const pluginPromise = OpenCodeMemPlugin(createPluginInput()); + await flushMicrotasks(); + + expect(testState.events).toEqual(["server:start"]); + expect(testState.warmupCalls).toBe(0); + + resolveServer(createMockWebServer()); + await pluginPromise; + + expect(testState.events).toEqual(["server:start", "server:ready", "warmup:start"]); + }); + + it("keeps the memory tool non-blocking while initialization is still running", async () => { + CONFIG.webServerEnabled = false; + + testState.warmupImpl = () => new Promise(() => {}); + + const hooks = await OpenCodeMemPlugin(createPluginInput()); + expect(testState.warmupCalls).toBe(1); + + const result = await Promise.race([ + (hooks.tool as any).memory.execute({}, { sessionID: "session-1" }), + new Promise((resolve) => originalSetTimeout(() => resolve("__timeout__"), 25)), + ]); + + expect(result).not.toBe("__timeout__"); + expect(JSON.parse(String(result))).toEqual({ + success: false, + error: "Memory system is initializing.", + }); + expect(testState.warmupCalls).toBe(1); + }); + + it("allows a new warmup attempt after a timed-out warmup clears stale state", async () => { + CONFIG.webServerEnabled = false; + + let attempt = 0; + testState.warmupImpl = () => { + attempt += 1; + if (attempt === 1) { + return new Promise(() => {}); + } + return Promise.resolve(); + }; + + const hooks = await OpenCodeMemPlugin(createPluginInput()); + expect(testState.warmupCalls).toBe(1); + expect(testState.timeoutCallbacks).toHaveLength(1); + + testState.timeoutCallbacks[0]?.(); + await flushMicrotasks(); + + const result = JSON.parse( + String(await (hooks.tool as any).memory.execute({}, { sessionID: "session-1" })) + ); - expect(toolGuard).toBeGreaterThan(-1); - expect(retryCall).toBeGreaterThan(toolGuard); + expect(result).toEqual({ success: false, error: "Memory system is initializing." }); + expect(testState.resetWarmupStateCalls).toBe(1); + expect(testState.warmupCalls).toBe(2); }); }); From e1d8d0d5dd21736824b41467cd4ed32a5863fdc4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=88=B7=E4=B8=AA?= <453241564@qq.com> Date: Thu, 9 Apr 2026 23:22:43 +0800 Subject: [PATCH 4/5] test(plugin): cover failed startup ordering ## Background - The runtime regression only covered the successful web server startup path. - Reviewer feedback is safer to answer when both startup promise outcomes are exercised. ## Changes - Add a runtime regression test for the rejected `startWebServer(...)` path. - Verify background warmup does not start while startup is still pending and only starts after the failed startup attempt settles. ## Validation - bun test tests/web-server-startup-order.test.ts - bun test tests/plugin-loader-contract.test.ts tests/web-server-startup-order.test.ts - bun run typecheck --- tests/web-server-startup-order.test.ts | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/tests/web-server-startup-order.test.ts b/tests/web-server-startup-order.test.ts index b5a8e8b..c364445 100644 --- a/tests/web-server-startup-order.test.ts +++ b/tests/web-server-startup-order.test.ts @@ -263,6 +263,31 @@ describe("web server startup order", () => { expect(testState.events).toEqual(["server:start", "server:ready", "warmup:start"]); }); + it("waits for a failed web server startup attempt before background warmup begins", async () => { + let rejectServer!: (error: Error) => void; + + testState.startWebServerImpl = () => { + testState.events.push("server:start"); + return new Promise((_resolve, reject) => { + rejectServer = (error) => { + testState.events.push("server:failed"); + reject(error); + }; + }); + }; + + const pluginPromise = OpenCodeMemPlugin(createPluginInput()); + await flushMicrotasks(); + + expect(testState.events).toEqual(["server:start"]); + expect(testState.warmupCalls).toBe(0); + + rejectServer(new Error("bind failed")); + await pluginPromise; + + expect(testState.events).toEqual(["server:start", "server:failed", "warmup:start"]); + }); + it("keeps the memory tool non-blocking while initialization is still running", async () => { CONFIG.webServerEnabled = false; From 72f67dabbe3c1dea1c4d9346c0e47329cba3716f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=88=B7=E4=B8=AA?= <453241564@qq.com> Date: Sun, 12 Apr 2026 15:05:52 +0800 Subject: [PATCH 5/5] fix(embedding): prevent stale warmup retries from overlapping ## Background - Background warmup timeouts can call resetWarmupState() while the previous initialization is still running. - Clearing initPromise alone allowed a second warmup to start and let stale results race with the active initialization. ## Changes - Add generation fencing and a reset signal in EmbeddingService so reset wakes waiting callers and moves them onto a fresh warmup attempt. - Only let the active generation publish initialization state, preventing stale success, stale failure, and hanging initializations from corrupting the current model state. - Add regression tests covering stale success, stale failure, hanging initialization, and retry-after-failure paths. ## Validation - bun test tests/embedding.test.ts - bun test tests/web-server-startup-order.test.ts - bun run typecheck --- src/services/embedding.ts | 69 +++++++++-- tests/embedding.test.ts | 252 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 309 insertions(+), 12 deletions(-) create mode 100644 tests/embedding.test.ts diff --git a/src/services/embedding.ts b/src/services/embedding.ts index cf55614..21e1a47 100644 --- a/src/services/embedding.ts +++ b/src/services/embedding.ts @@ -31,6 +31,8 @@ function withTimeout(promise: Promise, ms: number): Promise { export class EmbeddingService { private pipe: any = null; private initPromise: Promise | null = null; + private initGeneration = 0; + private resetSignal = this.createResetSignal(); public isWarmedUp: boolean = false; private cache: Map = new Map(); private cachedModelName: string | null = null; @@ -42,30 +44,76 @@ export class EmbeddingService { return (globalThis as any)[GLOBAL_EMBEDDING_KEY]; } + private createResetSignal(): { promise: Promise; resolve: () => void } { + let resolve!: () => void; + const promise = new Promise((resolver) => { + resolve = resolver; + }); + + return { promise, resolve }; + } + async warmup(progressCallback?: (progress: any) => void): Promise { - if (this.isWarmedUp) return; - if (this.initPromise) return this.initPromise; - this.initPromise = this.initializeModel(progressCallback); - return this.initPromise; + while (!this.isWarmedUp) { + if (!this.initPromise) { + const generation = ++this.initGeneration; + const initPromise = this.initializeModel(generation, progressCallback).finally(() => { + if (this.initPromise === initPromise) { + this.initPromise = null; + } + }); + + this.initPromise = initPromise; + } + + await Promise.race([this.initPromise, this.resetSignal.promise]); + } } resetWarmupState(): void { - this.initPromise = null; + const resetSignal = this.resetSignal; + this.resetSignal = this.createResetSignal(); + this.initGeneration += 1; + this.isWarmedUp = false; + this.pipe = null; + if (this.initPromise) { + this.initPromise = null; + } + + this.clearCache(); + resetSignal.resolve(); } - private async initializeModel(progressCallback?: (progress: any) => void): Promise { + private async initializeModel( + generation: number, + progressCallback?: (progress: any) => void + ): Promise { try { if (CONFIG.embeddingApiUrl && CONFIG.embeddingApiKey) { + if (generation !== this.initGeneration) { + return; + } + this.isWarmedUp = true; return; } + const { pipeline } = await ensureTransformersLoaded(); - this.pipe = await pipeline("feature-extraction", CONFIG.embeddingModel, { + const pipe = await pipeline("feature-extraction", CONFIG.embeddingModel, { progress_callback: progressCallback, }); + + if (generation !== this.initGeneration) { + return; + } + + this.pipe = pipe; this.isWarmedUp = true; } catch (error) { - this.initPromise = null; + if (generation !== this.initGeneration) { + return; + } + log("Failed to initialize embedding model", { error: String(error) }); throw error; } @@ -80,12 +128,9 @@ export class EmbeddingService { const cached = this.cache.get(text); if (cached) return cached; - if (!this.isWarmedUp && !this.initPromise) { + if (!this.isWarmedUp) { await this.warmup(); } - if (this.initPromise) { - await this.initPromise; - } let result: Float32Array; diff --git a/tests/embedding.test.ts b/tests/embedding.test.ts new file mode 100644 index 0000000..2b7ff27 --- /dev/null +++ b/tests/embedding.test.ts @@ -0,0 +1,252 @@ +import { afterEach, beforeEach, describe, expect, it, mock } from "bun:test"; +import { CONFIG } from "../src/config.ts"; + +const originalConfig = { + storagePath: CONFIG.storagePath, + embeddingApiUrl: CONFIG.embeddingApiUrl, + embeddingApiKey: CONFIG.embeddingApiKey, + embeddingModel: CONFIG.embeddingModel, +}; + +const mockEnv = { + allowLocalModels: false, + allowRemoteModels: false, + cacheDir: "", +}; + +let pipelineCalls = 0; +let pipelineImpl: () => Promise = async () => async () => ({ data: new Float32Array([1]) }); + +mock.module("@xenova/transformers", () => ({ + env: mockEnv, + pipeline: (..._args: unknown[]) => { + pipelineCalls += 1; + return pipelineImpl(); + }, +})); + +const { EmbeddingService } = await import("../src/services/embedding.ts"); + +async function flushMicrotasks() { + await Promise.resolve(); + await Promise.resolve(); +} + +describe("EmbeddingService warmup", () => { + beforeEach(() => { + pipelineCalls = 0; + pipelineImpl = async () => async () => ({ data: new Float32Array([1]) }); + mockEnv.allowLocalModels = false; + mockEnv.allowRemoteModels = false; + mockEnv.cacheDir = ""; + CONFIG.storagePath = "/tmp/opencode-mem-test"; + CONFIG.embeddingApiUrl = ""; + CONFIG.embeddingApiKey = ""; + CONFIG.embeddingModel = "test-model"; + }); + + afterEach(() => { + CONFIG.storagePath = originalConfig.storagePath; + CONFIG.embeddingApiUrl = originalConfig.embeddingApiUrl; + CONFIG.embeddingApiKey = originalConfig.embeddingApiKey; + CONFIG.embeddingModel = originalConfig.embeddingModel; + }); + + it("starts a fresh warmup generation after reset and ignores the stale result", async () => { + let pipelineAttempt = 0; + let firstWarmupSettled = false; + let markFirstPipelineStarted!: () => void; + let markSecondPipelineStarted!: () => void; + let resolveFirstPipeline!: (value: any) => void; + let resolveSecondPipeline!: (value: any) => void; + const firstPipelineStarted = new Promise((resolve) => { + markFirstPipelineStarted = resolve; + }); + const secondPipelineStarted = new Promise((resolve) => { + markSecondPipelineStarted = resolve; + }); + + pipelineImpl = () => + new Promise((resolve) => { + pipelineAttempt += 1; + + if (pipelineAttempt === 1) { + markFirstPipelineStarted(); + resolveFirstPipeline = resolve; + return; + } + + if (pipelineAttempt === 2) { + markSecondPipelineStarted(); + resolveSecondPipeline = resolve; + return; + } + + resolve(async () => ({ data: new Float32Array([1]) })); + }); + + const service = new EmbeddingService(); + const firstWarmup = service.warmup(); + void firstWarmup.then(() => { + firstWarmupSettled = true; + }); + + await firstPipelineStarted; + service.resetWarmupState(); + + const secondWarmup = service.warmup(); + await secondPipelineStarted; + + expect(pipelineCalls).toBe(2); + + resolveFirstPipeline(async () => ({ data: new Float32Array([1]) })); + await flushMicrotasks(); + + expect(firstWarmupSettled).toBe(false); + expect(service.isWarmedUp).toBe(false); + + resolveSecondPipeline(async () => ({ data: new Float32Array([1]) })); + await Promise.all([firstWarmup, secondWarmup]); + + expect(service.isWarmedUp).toBe(true); + expect(pipelineCalls).toBe(2); + }); + + it("keeps waiting for the new generation when the stale initialization rejects", async () => { + let pipelineAttempt = 0; + let firstWarmupRejected = false; + let markFirstPipelineStarted!: () => void; + let markSecondPipelineStarted!: () => void; + let rejectFirstPipeline!: (reason?: unknown) => void; + let resolveSecondPipeline!: (value: any) => void; + const firstPipelineStarted = new Promise((resolve) => { + markFirstPipelineStarted = resolve; + }); + const secondPipelineStarted = new Promise((resolve) => { + markSecondPipelineStarted = resolve; + }); + + pipelineImpl = () => + new Promise((resolve, reject) => { + pipelineAttempt += 1; + + if (pipelineAttempt === 1) { + markFirstPipelineStarted(); + rejectFirstPipeline = reject; + return; + } + + if (pipelineAttempt === 2) { + markSecondPipelineStarted(); + resolveSecondPipeline = resolve; + return; + } + + resolve(async () => ({ data: new Float32Array([1]) })); + }); + + const service = new EmbeddingService(); + const firstWarmup = service.warmup(); + void firstWarmup.catch(() => { + firstWarmupRejected = true; + }); + + await firstPipelineStarted; + service.resetWarmupState(); + + const secondWarmup = service.warmup(); + await secondPipelineStarted; + + rejectFirstPipeline(new Error("stale boom")); + await flushMicrotasks(); + + expect(firstWarmupRejected).toBe(false); + expect(service.isWarmedUp).toBe(false); + + resolveSecondPipeline(async () => ({ data: new Float32Array([1]) })); + await Promise.all([firstWarmup, secondWarmup]); + + expect(firstWarmupRejected).toBe(false); + expect(service.isWarmedUp).toBe(true); + expect(pipelineCalls).toBe(2); + }); + + it("wakes waiters blocked on a stale hanging initialization after reset", async () => { + let pipelineAttempt = 0; + let firstWarmupResolved = false; + let markFirstPipelineStarted!: () => void; + let markSecondPipelineStarted!: () => void; + let resolveSecondPipeline!: (value: any) => void; + const firstPipelineStarted = new Promise((resolve) => { + markFirstPipelineStarted = resolve; + }); + const secondPipelineStarted = new Promise((resolve) => { + markSecondPipelineStarted = resolve; + }); + + pipelineImpl = () => { + pipelineAttempt += 1; + + if (pipelineAttempt === 1) { + markFirstPipelineStarted(); + return new Promise(() => {}); + } + + if (pipelineAttempt === 2) { + return new Promise((resolve) => { + markSecondPipelineStarted(); + resolveSecondPipeline = resolve; + }); + } + + return Promise.resolve(async () => ({ data: new Float32Array([1]) })); + }; + + const service = new EmbeddingService(); + const firstWarmup = service.warmup(); + void firstWarmup.then(() => { + firstWarmupResolved = true; + }); + + await firstPipelineStarted; + service.resetWarmupState(); + + const secondWarmup = service.warmup(); + await secondPipelineStarted; + + expect(firstWarmupResolved).toBe(false); + expect(service.isWarmedUp).toBe(false); + + resolveSecondPipeline(async () => ({ data: new Float32Array([1]) })); + await Promise.all([firstWarmup, secondWarmup]); + + expect(firstWarmupResolved).toBe(true); + expect(service.isWarmedUp).toBe(true); + expect(pipelineCalls).toBe(2); + }); + + it("allows a new warmup attempt after the previous initialization fails", async () => { + const service = new EmbeddingService(); + + pipelineImpl = async () => { + throw new Error("boom"); + }; + + let thrown: unknown; + try { + await service.warmup(); + } catch (error) { + thrown = error; + } + + expect(thrown).toBeInstanceOf(Error); + expect((thrown as Error).message).toBe("boom"); + expect(service.isWarmedUp).toBe(false); + + pipelineImpl = async () => async () => ({ data: new Float32Array([1]) }); + + await service.warmup(); + expect(service.isWarmedUp).toBe(true); + expect(pipelineCalls).toBe(2); + }); +});