From d7d80953b35363f5c579738347f254a81fa8527e Mon Sep 17 00:00:00 2001 From: pelagius Date: Fri, 17 Apr 2026 10:47:10 +0800 Subject: [PATCH 1/4] fix(openclaw): prevent duplicate viewer instances on gateway restart Add module-level singleton guard (activeInstances Map) keyed by stateDir to detect and clean up previous instances when register() is called multiple times (deferred reload, gateway restart). Key changes: - Resolve stateDir early in register() to check for duplicates - Snapshot previous instance and defer cleanup to startServiceCore() - Gracefully stop previous viewer/hub/worker before binding new ports - Update ViewerServer.stop() to return Promise for proper port release - Add setTimeout guard to prevent stale instances from self-starting - Update test mocks to match async stop() signature --- apps/memos-local-openclaw/index.ts | 50 ++++++++++++++++++- .../memos-local-openclaw/src/viewer/server.ts | 21 +++++++- .../tests/shutdown-lifecycle.test.ts | 2 +- 3 files changed, 68 insertions(+), 5 deletions(-) diff --git a/apps/memos-local-openclaw/index.ts b/apps/memos-local-openclaw/index.ts index 5e224519..c0dd5c36 100644 --- a/apps/memos-local-openclaw/index.ts +++ b/apps/memos-local-openclaw/index.ts @@ -150,6 +150,19 @@ const pluginConfigSchema = { }, }; +// ─── Module-level singleton guard ─── +// Prevents duplicate viewers/stores when OpenClaw calls register() multiple +// times for the SAME stateDir (e.g. deferred reload, gateway restart). +// Keyed by stateDir so that truly independent instances (different data dirs, +// as in tests) are not accidentally torn down. +const activeInstances = new Map; + hubServer: InstanceType | null; + worker: InstanceType; + store: InstanceType; + telemetry: InstanceType; +}>(); + const memosLocalPlugin = { id: "memos-local-openclaw-plugin", name: "MemOS Local Memory", @@ -160,6 +173,19 @@ const memosLocalPlugin = { configSchema: pluginConfigSchema, register(api: OpenClawPluginApi) { + // Resolve stateDir early so we can check for a duplicate instance with the + // same data directory (deferred reload / gateway restart scenario). + const stateDir = process.env.OPENCLAW_STATE_DIR || api.resolvePath("~/.openclaw"); + + // Snapshot the previous instance for this stateDir so startServiceCore() + // can tear it down asynchronously (awaiting port release) before starting + // the new viewer. Instances with a different stateDir are left untouched. + const instanceToReplace = activeInstances.get(stateDir) ?? null; + activeInstances.delete(stateDir); + if (instanceToReplace) { + api.logger.info("memos-local: previous instance detected, will stop before starting new viewer"); + } + api.registerMemoryCapability({ promptBuilder: buildMemoryPromptSection, }); @@ -286,7 +312,6 @@ const memosLocalPlugin = { } let pluginCfg = (api.pluginConfig ?? {}) as Record; - const stateDir = process.env.OPENCLAW_STATE_DIR || api.resolvePath("~/.openclaw"); // Fallback: read config from file if not provided by OpenClaw const configPath = path.join(stateDir, "state", "memos-local", "config.json"); @@ -2378,6 +2403,9 @@ Groups: ${groupNames.length > 0 ? groupNames.join(", ") : "(none)"}`, ? new HubServer({ store, log: ctx.log, config: ctx.config, dataDir: stateDir, embedder, defaultHubPort: derivedHubPort }) : null; + // Track this instance so the next register() with the same stateDir can tear it down + activeInstances.set(stateDir, { viewer, hubServer, worker, store, telemetry }); + // ─── Service lifecycle ─── let serviceStarted = false; @@ -2386,6 +2414,21 @@ Groups: ${groupNames.length > 0 ? groupNames.join(", ") : "(none)"}`, if (serviceStarted) return; serviceStarted = true; + // Gracefully shut down the previous instance before binding new ports + if (instanceToReplace) { + api.logger.info("memos-local: stopping previous instance..."); + try { + await instanceToReplace.viewer.stop(); + await instanceToReplace.hubServer?.stop(); + await instanceToReplace.worker.flush().catch(() => {}); + await instanceToReplace.telemetry.shutdown().catch(() => {}); + instanceToReplace.store.close(); + } catch (err) { + api.logger.warn(`memos-local: previous instance cleanup error: ${err instanceof Error ? err.message : String(err)}`); + } + api.logger.info("memos-local: previous instance stopped"); + } + if (hubServer) { const hubUrl = await hubServer.start(); api.logger.info(`memos-local: hub started at ${hubUrl}`); @@ -2429,10 +2472,11 @@ Groups: ${groupNames.length > 0 ? groupNames.join(", ") : "(none)"}`, id: "memos-local-openclaw-plugin", start: async () => { await startServiceCore(); }, stop: async () => { + activeInstances.delete(stateDir); await worker.flush(); await telemetry.shutdown(); await hubServer?.stop(); - viewer.stop(); + await viewer.stop(); store.close(); api.logger.info("memos-local: stopped"); }, @@ -2445,6 +2489,8 @@ Groups: ${groupNames.length > 0 ? groupNames.join(", ") : "(none)"}`, // service.start() immediately after registration. const SELF_START_DELAY_MS = 0; setTimeout(() => { + // Abort if this instance was already replaced by a newer register() call + if (activeInstances.get(stateDir)?.viewer !== viewer) return; if (!serviceStarted) { api.logger.info("memos-local: service.start() not called by host, self-starting viewer..."); startServiceCore().catch((err) => { diff --git a/apps/memos-local-openclaw/src/viewer/server.ts b/apps/memos-local-openclaw/src/viewer/server.ts index 852f3eda..48182c30 100644 --- a/apps/memos-local-openclaw/src/viewer/server.ts +++ b/apps/memos-local-openclaw/src/viewer/server.ts @@ -211,13 +211,30 @@ export class ViewerServer { } } - stop(): void { + stop(): Promise { this.stopHubHeartbeat(); this.stopNotifPoll(); for (const c of this.notifSSEClients) { try { c.end(); } catch {} } this.notifSSEClients = []; - this.server?.close(); + if (!this.server) return Promise.resolve(); + const srv = this.server; this.server = null; + return new Promise((resolve) => { + const timeout = setTimeout(() => resolve(), 3000); + srv.close(() => { clearTimeout(timeout); resolve(); }); + // Force-close idle keep-alive sockets. closeAllConnections is + // available from Node 18.2; fall back to destroying tracked sockets. + if (typeof srv.closeAllConnections === "function") { + srv.closeAllConnections(); + } else { + // Older Node: close idle connections via closeIdleConnections + // (18.0+) or just unref so the event loop can exit. + if (typeof (srv as any).closeIdleConnections === "function") { + (srv as any).closeIdleConnections(); + } + srv.unref(); + } + }); } getResetToken(): string { diff --git a/apps/memos-local-openclaw/tests/shutdown-lifecycle.test.ts b/apps/memos-local-openclaw/tests/shutdown-lifecycle.test.ts index f1471cd7..382be4eb 100644 --- a/apps/memos-local-openclaw/tests/shutdown-lifecycle.test.ts +++ b/apps/memos-local-openclaw/tests/shutdown-lifecycle.test.ts @@ -83,7 +83,7 @@ describe("shutdown lifecycle", () => { class MockViewer { async start(): Promise { return "http://127.0.0.1:18799"; } - stop(): void { events.push("viewer-stop"); } + async stop(): Promise { events.push("viewer-stop"); } getResetToken(): string { return "token"; } } From 371ff473017a6634a4c8e57e8c37ff94be190d38 Mon Sep 17 00:00:00 2001 From: pelagius Date: Fri, 17 Apr 2026 14:19:10 +0800 Subject: [PATCH 2/4] fix(openclaw): add periodic cleanup for expired remoteHitMap entries remoteHitMap entries accumulate when users search but never request detail. Add a 5-minute interval timer to sweep expired entries, following the same pattern as offlineCheckTimer. Clean up the timer in stop() to prevent leaks on shutdown. --- apps/memos-local-openclaw/src/hub/server.ts | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/apps/memos-local-openclaw/src/hub/server.ts b/apps/memos-local-openclaw/src/hub/server.ts index d8b697d7..3f77a5f3 100644 --- a/apps/memos-local-openclaw/src/hub/server.ts +++ b/apps/memos-local-openclaw/src/hub/server.ts @@ -38,7 +38,9 @@ export class HubServer { private static readonly OFFLINE_THRESHOLD_MS = 2 * 60 * 1000; private static readonly OFFLINE_CHECK_INTERVAL_MS = 30 * 1000; + private static readonly REMOTE_HIT_CLEANUP_INTERVAL_MS = 5 * 60 * 1000; private offlineCheckTimer?: ReturnType; + private remoteHitCleanupTimer?: ReturnType; private knownOnlineUsers = new Set(); constructor(private opts: HubServerOptions) { @@ -123,6 +125,7 @@ export class HubServer { this.initOnlineTracking(); this.offlineCheckTimer = setInterval(() => this.checkOfflineUsers(), HubServer.OFFLINE_CHECK_INTERVAL_MS); + this.remoteHitCleanupTimer = setInterval(() => this.cleanExpiredRemoteHits(), HubServer.REMOTE_HIT_CLEANUP_INTERVAL_MS); this.backfillMemoryEmbeddings(); @@ -131,6 +134,7 @@ export class HubServer { async stop(): Promise { if (this.offlineCheckTimer) { clearInterval(this.offlineCheckTimer); this.offlineCheckTimer = undefined; } + if (this.remoteHitCleanupTimer) { clearInterval(this.remoteHitCleanupTimer); this.remoteHitCleanupTimer = undefined; } if (!this.server) return; try { @@ -1133,6 +1137,15 @@ export class HubServer { } catch { /* best-effort */ } } + private cleanExpiredRemoteHits(): void { + const now = Date.now(); + let removed = 0; + for (const [key, hit] of this.remoteHitMap) { + if (hit.expiresAt < now) { this.remoteHitMap.delete(key); removed++; } + } + if (removed > 0) this.opts.log.debug(`Hub: cleaned ${removed} expired remote hit(s) (remaining=${this.remoteHitMap.size})`); + } + private authenticate(req: http.IncomingMessage) { const header = req.headers.authorization; if (!header || !header.startsWith("Bearer ")) return null; From 6aedbb16240d0049b2735ef571dcc9e7971c50ef Mon Sep 17 00:00:00 2001 From: pelagius Date: Fri, 17 Apr 2026 14:31:13 +0800 Subject: [PATCH 3/4] test(openclaw): add test for remoteHitMap cleanup logic Verifies that cleanExpiredRemoteHits correctly removes expired entries from the map, preventing memory leaks from unused search results. --- .../tests/hub-server.test.ts | 33 +++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/apps/memos-local-openclaw/tests/hub-server.test.ts b/apps/memos-local-openclaw/tests/hub-server.test.ts index b348e2e7..f855d5e4 100644 --- a/apps/memos-local-openclaw/tests/hub-server.test.ts +++ b/apps/memos-local-openclaw/tests/hub-server.test.ts @@ -498,3 +498,36 @@ describe("hub skill pipeline", () => { expect(store.getHubSkillBySource(userId, "skill-source-1")).toBeNull(); }); }); + +describe("hub server remote hit cleanup", () => { + it("should clean up expired remote hits", async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "memos-hub-cleanup-")); + dirs.push(dir); + const dbPath = path.join(dir, "test.db"); + const store = new SqliteStore(dbPath, noopLog); + stores.push(store); + const server = new HubServer({ + store, + log: noopLog, + config: { sharing: { enabled: true, role: "hub", hub: { port: 18919, teamName: "Cleanup", teamToken: "cleanup-token" } } }, + dataDir: dir, + } as any); + servers.push(server); + await server.start(); + + const map = (server as any).remoteHitMap as Map; + const hitId = "test-hit-1"; + map.set(hitId, { + chunkId: "chunk-1", + type: "chunk", + expiresAt: Date.now() - 1000, // Already expired + requesterUserId: "user-1", + }); + expect(map.size).toBe(1); + + (server as any).cleanExpiredRemoteHits(); + + expect(map.size).toBe(0); + expect(map.get(hitId)).toBeUndefined(); + }); +}); From a5f23b07c1b52d3efa6cf33f5af4fa3c9a5cf198 Mon Sep 17 00:00:00 2001 From: pelagius Date: Fri, 17 Apr 2026 19:53:11 +0800 Subject: [PATCH 4/4] fix(openclaw): resolve environment variables in Viewer config API The Viewer's /api/config endpoint now recursively resolves environment variable references stored as { source: 'env', id: 'VAR_NAME' } in openclaw.json, so API keys and other sensitive values don't need to be hardcoded in the config file. Fixes #1490 Co-authored-by: Qwen-Coder --- .../memos-local-openclaw/src/viewer/server.ts | 24 ++++++++++++++++++- apps/memos-local-plugin/src/viewer/server.ts | 24 ++++++++++++++++++- 2 files changed, 46 insertions(+), 2 deletions(-) diff --git a/apps/memos-local-openclaw/src/viewer/server.ts b/apps/memos-local-openclaw/src/viewer/server.ts index 48182c30..8b5285b9 100644 --- a/apps/memos-local-openclaw/src/viewer/server.ts +++ b/apps/memos-local-openclaw/src/viewer/server.ts @@ -3031,6 +3031,28 @@ export class ViewerServer { res.end(JSON.stringify({ ips })); } + /** + * Recursively resolve environment variable references in config values. + * OpenClaw stores env refs as { source: "env", id: "VAR_NAME" }. + */ + private static resolveEnvVars(obj: unknown): unknown { + if (obj === null || obj === undefined) return obj; + if (typeof obj === "string") return obj; + if (Array.isArray(obj)) return obj.map(item => ViewerServer.resolveEnvVars(item)); + if (typeof obj === "object") { + const entry = obj as Record; + if (entry.source === "env" && typeof entry.id === "string") { + return process.env[entry.id] ?? ""; + } + const result: Record = {}; + for (const [key, value] of Object.entries(entry)) { + result[key] = ViewerServer.resolveEnvVars(value); + } + return result; + } + return obj; + } + private serveConfig(res: http.ServerResponse): void { try { const cfgPath = this.getOpenClawConfigPath(); @@ -3045,7 +3067,7 @@ export class ViewerServer { ?? entries["memos-lite-openclaw-plugin"]?.config ?? entries["memos-lite"]?.config ?? {}; - const result: Record = { ...pluginEntry }; + const result: Record = ViewerServer.resolveEnvVars(pluginEntry) as Record; const topEntry = entries["memos-local-openclaw-plugin"] ?? entries["memos-local"] ?? entries["memos-lite-openclaw-plugin"] diff --git a/apps/memos-local-plugin/src/viewer/server.ts b/apps/memos-local-plugin/src/viewer/server.ts index 8435a4e5..a7b3d7f4 100644 --- a/apps/memos-local-plugin/src/viewer/server.ts +++ b/apps/memos-local-plugin/src/viewer/server.ts @@ -3080,6 +3080,28 @@ export class ViewerServer { res.end(JSON.stringify({ ips })); } + /** + * Recursively resolve environment variable references in config values. + * OpenClaw stores env refs as { source: "env", id: "VAR_NAME" }. + */ + private static resolveEnvVars(obj: unknown): unknown { + if (obj === null || obj === undefined) return obj; + if (typeof obj === "string") return obj; + if (Array.isArray(obj)) return obj.map(item => ViewerServer.resolveEnvVars(item)); + if (typeof obj === "object") { + const entry = obj as Record; + if (entry.source === "env" && typeof entry.id === "string") { + return process.env[entry.id] ?? ""; + } + const result: Record = {}; + for (const [key, value] of Object.entries(entry)) { + result[key] = ViewerServer.resolveEnvVars(value); + } + return result; + } + return obj; + } + private serveConfig(res: http.ServerResponse): void { try { const cfgPath = this.getOpenClawConfigPath(); @@ -3094,7 +3116,7 @@ export class ViewerServer { ?? entries["memos-lite-openclaw-plugin"]?.config ?? entries["memos-lite"]?.config ?? {}; - const result: Record = { ...pluginEntry }; + const result: Record = ViewerServer.resolveEnvVars(pluginEntry) as Record; const topEntry = entries["memos-local-openclaw-plugin"] ?? entries["memos-local"] ?? entries["memos-lite-openclaw-plugin"]