context-mode 1.0.127 → 1.0.129
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/.openclaw-plugin/openclaw.plugin.json +1 -1
- package/.openclaw-plugin/package.json +1 -1
- package/build/adapters/detect.d.ts +30 -2
- package/build/adapters/detect.js +31 -0
- package/build/adapters/pi/mcp-bridge.js +19 -4
- package/build/cli.js +31 -0
- package/build/db-base.js +80 -4
- package/build/util/db-lock.d.ts +65 -0
- package/build/util/db-lock.js +166 -0
- package/build/util/sibling-mcp.d.ts +79 -0
- package/build/util/sibling-mcp.js +181 -0
- package/cli.bundle.mjs +131 -131
- package/hooks/session-db.bundle.mjs +2 -2
- package/openclaw.plugin.json +1 -1
- package/package.json +1 -1
- package/server.bundle.mjs +86 -86
|
@@ -6,14 +6,14 @@
|
|
|
6
6
|
},
|
|
7
7
|
"metadata": {
|
|
8
8
|
"description": "Claude Code plugins by Mert Koseoğlu",
|
|
9
|
-
"version": "1.0.
|
|
9
|
+
"version": "1.0.129"
|
|
10
10
|
},
|
|
11
11
|
"plugins": [
|
|
12
12
|
{
|
|
13
13
|
"name": "context-mode",
|
|
14
14
|
"source": "./",
|
|
15
15
|
"description": "Claude Code MCP plugin that saves 98% of your context window. Sandboxed code execution in 11 languages, FTS5 knowledge base with BM25 ranking, and intent-driven search.",
|
|
16
|
-
"version": "1.0.
|
|
16
|
+
"version": "1.0.129",
|
|
17
17
|
"author": {
|
|
18
18
|
"name": "Mert Koseoğlu"
|
|
19
19
|
},
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "context-mode",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.129",
|
|
4
4
|
"description": "MCP server that saves 98% of your context window with session continuity. Sandboxed code execution in 11 languages, FTS5 knowledge base with BM25 ranking, and automatic state restore across compactions.",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "Mert Koseoğlu",
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
"name": "Context Mode",
|
|
4
4
|
"kind": "tool",
|
|
5
5
|
"description": "OpenClaw plugin that saves 98% of your context window. Sandboxed code execution in 11 languages, FTS5 knowledge base with BM25 ranking, and intent-driven search.",
|
|
6
|
-
"version": "1.0.
|
|
6
|
+
"version": "1.0.129",
|
|
7
7
|
"sandbox": {
|
|
8
8
|
"mode": "permissive",
|
|
9
9
|
"filesystem_access": "full",
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "context-mode",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.129",
|
|
4
4
|
"description": "OpenClaw plugin that saves 98% of your context window. Sandboxed code execution in 11 languages, FTS5 knowledge base with BM25 ranking, and intent-driven search.",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "Mert Koseoğlu",
|
|
@@ -34,12 +34,20 @@ export declare function __seedClaudeCodePluginCacheMissForTests(): void;
|
|
|
34
34
|
* `resolveProjectDir({ strictPlatform })` to form the candidate list,
|
|
35
35
|
* and by Pi's bridge to scrub foreign workspace vars on child spawn.
|
|
36
36
|
* - `identification`: env var only signals which host is running; carries
|
|
37
|
-
* no project path.
|
|
38
|
-
*
|
|
37
|
+
* no project path. PRESERVED in normal operation (some are load-bearing
|
|
38
|
+
* for hook integrations on the host that owns them, e.g. CLAUDE_PLUGIN_ROOT
|
|
39
|
+
* for Claude Code's hook context).
|
|
39
40
|
*
|
|
40
41
|
* Issue #545 — algorithmic env-leak fix. The split allows resolveProjectDir
|
|
41
42
|
* to derive ALLOW (own workspace vars) and BAN (other platforms' workspace
|
|
42
43
|
* vars) sets from a single registry, satisfying MUST-3 (15 adapters equal).
|
|
44
|
+
*
|
|
45
|
+
* Issue #561 — FOREIGN identification vars MUST be scrubbed when spawning a
|
|
46
|
+
* child under a different host (e.g. Pi spawning context-mode child must
|
|
47
|
+
* scrub Claude Code identification vars CLAUDE_CODE_ENTRYPOINT /
|
|
48
|
+
* CLAUDE_PLUGIN_ROOT to prevent detectPlatform() in the child from
|
|
49
|
+
* misidentifying the host as claude-code and writing Pi's data into
|
|
50
|
+
* ~/.claude/context-mode/). See `foreignIdentificationEnv()` below.
|
|
43
51
|
*/
|
|
44
52
|
export type EnvVarRole = "workspace" | "identification";
|
|
45
53
|
export interface PlatformEnvEntry {
|
|
@@ -78,6 +86,26 @@ export declare function workspaceEnvVarsFor(platform: PlatformId): string[];
|
|
|
78
86
|
* workspace vars from spawned MCP child) and by the matrix regression test.
|
|
79
87
|
*/
|
|
80
88
|
export declare function foreignWorkspaceEnv(platform: PlatformId): Set<string>;
|
|
89
|
+
/**
|
|
90
|
+
* Issue #561 — return the union of identification env vars from ALL
|
|
91
|
+
* platforms EXCEPT the given one. Sibling of `foreignWorkspaceEnv`,
|
|
92
|
+
* filtered on `role === "identification"` instead of "workspace".
|
|
93
|
+
*
|
|
94
|
+
* Consumed by Pi's bridge env scrub: when Pi spawns the context-mode
|
|
95
|
+
* MCP child, the child inherits the host shell env including any
|
|
96
|
+
* identification vars set by a co-resident Claude Code session
|
|
97
|
+
* (CLAUDE_CODE_ENTRYPOINT / CLAUDE_PLUGIN_ROOT). Without scrubbing,
|
|
98
|
+
* `detectPlatform()` in the child falls through env priority order and
|
|
99
|
+
* resolves to claude-code first — Pi's session data then writes into
|
|
100
|
+
* `~/.claude/context-mode/` instead of Pi's own dir. Scrubbing FOREIGN
|
|
101
|
+
* identification vars (everyone else's) preserves Pi's OWN identification
|
|
102
|
+
* vars (PI_CONFIG_DIR / PI_SESSION_FILE / PI_COMPILED) so the child still
|
|
103
|
+
* detects pi correctly.
|
|
104
|
+
*
|
|
105
|
+
* Algorithmic, registry-driven — adding adapter #16 grows the scrub
|
|
106
|
+
* automatically (no edit to mcp-bridge.ts).
|
|
107
|
+
*/
|
|
108
|
+
export declare function foreignIdentificationEnv(platform: PlatformId): Set<string>;
|
|
81
109
|
/**
|
|
82
110
|
* Sync map from platform identifier → home-relative path segments where that
|
|
83
111
|
* platform stores its config. Mirrors the `super([...])` argument passed by
|
package/build/adapters/detect.js
CHANGED
|
@@ -222,6 +222,37 @@ export function foreignWorkspaceEnv(platform) {
|
|
|
222
222
|
}
|
|
223
223
|
return ban;
|
|
224
224
|
}
|
|
225
|
+
/**
|
|
226
|
+
* Issue #561 — return the union of identification env vars from ALL
|
|
227
|
+
* platforms EXCEPT the given one. Sibling of `foreignWorkspaceEnv`,
|
|
228
|
+
* filtered on `role === "identification"` instead of "workspace".
|
|
229
|
+
*
|
|
230
|
+
* Consumed by Pi's bridge env scrub: when Pi spawns the context-mode
|
|
231
|
+
* MCP child, the child inherits the host shell env including any
|
|
232
|
+
* identification vars set by a co-resident Claude Code session
|
|
233
|
+
* (CLAUDE_CODE_ENTRYPOINT / CLAUDE_PLUGIN_ROOT). Without scrubbing,
|
|
234
|
+
* `detectPlatform()` in the child falls through env priority order and
|
|
235
|
+
* resolves to claude-code first — Pi's session data then writes into
|
|
236
|
+
* `~/.claude/context-mode/` instead of Pi's own dir. Scrubbing FOREIGN
|
|
237
|
+
* identification vars (everyone else's) preserves Pi's OWN identification
|
|
238
|
+
* vars (PI_CONFIG_DIR / PI_SESSION_FILE / PI_COMPILED) so the child still
|
|
239
|
+
* detects pi correctly.
|
|
240
|
+
*
|
|
241
|
+
* Algorithmic, registry-driven — adding adapter #16 grows the scrub
|
|
242
|
+
* automatically (no edit to mcp-bridge.ts).
|
|
243
|
+
*/
|
|
244
|
+
export function foreignIdentificationEnv(platform) {
|
|
245
|
+
const ban = new Set();
|
|
246
|
+
for (const [p, vars] of PLATFORM_ENV_VARS) {
|
|
247
|
+
if (p === platform)
|
|
248
|
+
continue;
|
|
249
|
+
for (const v of vars) {
|
|
250
|
+
if (v.role === "identification")
|
|
251
|
+
ban.add(v.name);
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
return ban;
|
|
255
|
+
}
|
|
225
256
|
/**
|
|
226
257
|
* Sync map from platform identifier → home-relative path segments where that
|
|
227
258
|
* platform stores its config. Mirrors the `super([...])` argument passed by
|
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
*/
|
|
23
23
|
import { spawn, execSync } from "node:child_process";
|
|
24
24
|
import { detectRuntimes } from "../../runtime.js";
|
|
25
|
-
import { foreignWorkspaceEnv } from "../detect.js";
|
|
25
|
+
import { foreignWorkspaceEnv, foreignIdentificationEnv } from "../detect.js";
|
|
26
26
|
// ── Fork-bomb prevention (#516) ──────────────────────────────────────
|
|
27
27
|
//
|
|
28
28
|
// Original bug: `spawn(process.execPath, [serverScript])` recursively
|
|
@@ -154,12 +154,27 @@ export class MCPStdioClient {
|
|
|
154
154
|
// and Pi's sessions write into the wrong project. The ban list is
|
|
155
155
|
// derived ALGORITHMICALLY from PLATFORM_ENV_VARS (every other adapter's
|
|
156
156
|
// workspace-role vars), so adding adapter #16 grows the scrub
|
|
157
|
-
// automatically — no edit to this file.
|
|
158
|
-
//
|
|
159
|
-
//
|
|
157
|
+
// automatically — no edit to this file. Pi's own workspace vars and
|
|
158
|
+
// the universal escape hatch (CONTEXT_MODE_PROJECT_DIR) are NEVER
|
|
159
|
+
// scrubbed.
|
|
160
160
|
for (const banned of foreignWorkspaceEnv("pi")) {
|
|
161
161
|
delete childEnv[banned];
|
|
162
162
|
}
|
|
163
|
+
// Issue #561 — scrub foreign IDENTIFICATION env vars before spawn.
|
|
164
|
+
//
|
|
165
|
+
// Foreign identification vars hijack detectPlatform() — must scrub
|
|
166
|
+
// when spawning child under a different host (#561). When Pi runs
|
|
167
|
+
// co-resident with Claude Code, the inherited shell env carries
|
|
168
|
+
// CLAUDE_CODE_ENTRYPOINT and CLAUDE_PLUGIN_ROOT; the spawned MCP
|
|
169
|
+
// child's detectPlatform() then walks PLATFORM_ENV_VARS in priority
|
|
170
|
+
// order (claude-code first), returns claude-code, and Pi's session
|
|
171
|
+
// data lands in ~/.claude/context-mode/ instead of Pi's own dir.
|
|
172
|
+
// Pi's OWN identification vars (PI_CONFIG_DIR / PI_SESSION_FILE /
|
|
173
|
+
// PI_COMPILED) are excluded from the ban set so the child still
|
|
174
|
+
// detects pi correctly.
|
|
175
|
+
for (const banned of foreignIdentificationEnv("pi")) {
|
|
176
|
+
delete childEnv[banned];
|
|
177
|
+
}
|
|
163
178
|
this._spawnEnv = childEnv;
|
|
164
179
|
this.child = spawn(runtime, [this.serverScript], {
|
|
165
180
|
// Pipe stderr (#472 round-3): swallowing it via "ignore" hides
|
package/build/cli.js
CHANGED
|
@@ -22,6 +22,8 @@ import { fileURLToPath, pathToFileURL } from "node:url";
|
|
|
22
22
|
import { detectRuntimes, getRuntimeSummary, hasBunRuntime, getAvailableLanguages, } from "./runtime.js";
|
|
23
23
|
import { getHookScriptPaths } from "./util/hook-config.js";
|
|
24
24
|
import { resolveClaudeConfigDir } from "./util/claude-config.js";
|
|
25
|
+
// v1.0.128 — Issue #559 sibling MCP kill helpers (see PR-559-560-FIX-DESIGN.md).
|
|
26
|
+
import { discoverSiblingMcpPids, killSiblingMcpServers } from "./util/sibling-mcp.js";
|
|
25
27
|
// v1.0.119 — Issue #523 Layer 5 heal: post-bump assertion on .claude-plugin/plugin.json
|
|
26
28
|
// mcpServers args. Single source of truth shared with start.mjs HEAL block + postinstall.
|
|
27
29
|
// @ts-expect-error — JS module, no TS declarations
|
|
@@ -703,6 +705,35 @@ async function upgrade(opts) {
|
|
|
703
705
|
}
|
|
704
706
|
else {
|
|
705
707
|
p.log.info(`Update available: ${color.yellow("v" + localVersion)} → ${color.green("v" + newVersion)}`);
|
|
708
|
+
// v1.0.128 — Issue #559: terminate sibling MCP servers BEFORE installing
|
|
709
|
+
// new files. Historically /ctx-upgrade rsynced new code over the old
|
|
710
|
+
// tree but never signalled the running MCP server, so the previous
|
|
711
|
+
// version stayed alive holding stdio + DB handles. Across enough
|
|
712
|
+
// upgrades users observed 5+ context-mode start.mjs processes pinned
|
|
713
|
+
// to RAM. Discovery + kill must happen before npm install to avoid
|
|
714
|
+
// racing against the EXCLUSIVE lock the new server claims on first
|
|
715
|
+
// ctx_search (see #560 fix). Wrapped in try/catch so a missing pgrep
|
|
716
|
+
// (stripped Linux distro) or unavailable PowerShell (weird Windows)
|
|
717
|
+
// can never block the upgrade itself.
|
|
718
|
+
try {
|
|
719
|
+
const siblingPids = discoverSiblingMcpPids({
|
|
720
|
+
ownPid: process.pid,
|
|
721
|
+
ownPpid: process.ppid,
|
|
722
|
+
});
|
|
723
|
+
if (siblingPids.length > 0) {
|
|
724
|
+
const killReport = await killSiblingMcpServers({ pids: siblingPids });
|
|
725
|
+
if (killReport.totalKilled > 0) {
|
|
726
|
+
// Concise summary only — no PIDs in the user-facing log to keep
|
|
727
|
+
// the line readable. Plural-aware so "1 sibling MCP server" reads
|
|
728
|
+
// naturally alongside "3 sibling MCP servers".
|
|
729
|
+
const noun = killReport.totalKilled === 1
|
|
730
|
+
? "sibling MCP server"
|
|
731
|
+
: "sibling MCP servers";
|
|
732
|
+
p.log.info(color.dim(`Stopped ${killReport.totalKilled} ${noun} (SIGTERM: ${killReport.terminatedBySigterm}, SIGKILL: ${killReport.terminatedBySigkill})`));
|
|
733
|
+
}
|
|
734
|
+
}
|
|
735
|
+
}
|
|
736
|
+
catch { /* never block upgrade on discovery/kill failure */ }
|
|
706
737
|
// Step 2: Install dependencies + build
|
|
707
738
|
s.start("Installing dependencies & building");
|
|
708
739
|
npmExecFile(["install", "--no-audit", "--no-fund"], {
|
package/build/db-base.js
CHANGED
|
@@ -9,6 +9,12 @@ import { createRequire } from "node:module";
|
|
|
9
9
|
import { existsSync, unlinkSync, renameSync } from "node:fs";
|
|
10
10
|
import { tmpdir } from "node:os";
|
|
11
11
|
import { join } from "node:path";
|
|
12
|
+
// v1.0.128 — Issue #560 single-writer enforcement.
|
|
13
|
+
// Lockfile is the PRIMARY defense (clean UX with conflicting PID),
|
|
14
|
+
// `locking_mode = EXCLUSIVE` (applied in applyWALPragmas below) is the
|
|
15
|
+
// SECONDARY defense for the narrow race window between lockfile claim
|
|
16
|
+
// and the actual `new Database(...)` open. Both skip-gate on tmpdir paths.
|
|
17
|
+
import { acquireDbLock, releaseDbLock } from "./util/db-lock.js";
|
|
12
18
|
// ─────────────────────────────────────────────────────────
|
|
13
19
|
// bun:sqlite adapter (#45)
|
|
14
20
|
// ─────────────────────────────────────────────────────────
|
|
@@ -310,6 +316,15 @@ export function applyWALPragmas(db) {
|
|
|
310
316
|
db.pragma("mmap_size = 268435456");
|
|
311
317
|
}
|
|
312
318
|
catch { /* unsupported runtime */ }
|
|
319
|
+
// NOTE: `locking_mode = EXCLUSIVE` is intentionally NOT applied here.
|
|
320
|
+
// Multi-writer scenarios are valid: `ContentStore` (FTS5 shared
|
|
321
|
+
// knowledge base) is opened concurrently from multiple sessions on the
|
|
322
|
+
// SAME db file by design — applying EXCLUSIVE here would deadlock the
|
|
323
|
+
// second instance and break documented `withRetry`-based BUSY handling.
|
|
324
|
+
// Single-writer enforcement (lockfile + EXCLUSIVE) lives in
|
|
325
|
+
// `SQLiteBase` ctor which is opted into by per-project DBs (SessionDB).
|
|
326
|
+
// Different DB paths from different worktrees/sessions ARE concurrent
|
|
327
|
+
// by design — the lockfile is per-dbPath, not per-process.
|
|
313
328
|
}
|
|
314
329
|
// ─────────────────────────────────────────────────────────
|
|
315
330
|
// DB file helpers
|
|
@@ -450,14 +465,26 @@ export function renameCorruptDB(dbPath) {
|
|
|
450
465
|
* re-imports within the same fork process (ESM isolate mode clears
|
|
451
466
|
* module-level state but globalThis persists).
|
|
452
467
|
*/
|
|
453
|
-
|
|
468
|
+
// v1.0.128 — symbol name versioned because the value type changed from
|
|
469
|
+
// Set<DatabaseInstance> to Map<DatabaseInstance, string> (issue #560).
|
|
470
|
+
// A persistent global slot from a pre-v128 module would deserialize as
|
|
471
|
+
// the wrong shape and crash the exit hook iteration.
|
|
472
|
+
const _kLiveDBs = Symbol.for("__context_mode_live_dbs_v2__");
|
|
473
|
+
// v1.0.128 — issue #560: pair each DatabaseInstance with the dbPath that
|
|
474
|
+
// owns its lockfile. The exit hook needs both — closeDB(db) handles the
|
|
475
|
+
// WAL checkpoint, releaseDbLock(dbPath) drops the .lock file. We use a
|
|
476
|
+
// Map keyed by DatabaseInstance to keep API call sites unchanged.
|
|
454
477
|
const _liveDBs = (() => {
|
|
455
478
|
const g = globalThis;
|
|
456
479
|
if (!g[_kLiveDBs]) {
|
|
457
|
-
g[_kLiveDBs] = new
|
|
480
|
+
g[_kLiveDBs] = new Map();
|
|
458
481
|
process.on("exit", () => {
|
|
459
|
-
for (const db of g[_kLiveDBs]) {
|
|
482
|
+
for (const [db, dbPath] of g[_kLiveDBs]) {
|
|
460
483
|
closeDB(db);
|
|
484
|
+
// Release lock AFTER close so the WAL checkpoint inside closeDB
|
|
485
|
+
// runs while we still own the writer slot (no second-opener can
|
|
486
|
+
// race in mid-checkpoint).
|
|
487
|
+
releaseDbLock({ dbPath });
|
|
461
488
|
}
|
|
462
489
|
g[_kLiveDBs].clear();
|
|
463
490
|
});
|
|
@@ -470,11 +497,42 @@ export class SQLiteBase {
|
|
|
470
497
|
constructor(dbPath) {
|
|
471
498
|
const Database = loadDatabase();
|
|
472
499
|
this.#dbPath = dbPath;
|
|
500
|
+
// v1.0.128 — Issue #560 PRIMARY single-writer guard. Must claim
|
|
501
|
+
// BEFORE `new Database(...)` so a contending opener gets the clean
|
|
502
|
+
// DatabaseLockedError UX (PID + verbatim message) instead of the
|
|
503
|
+
// SQLITE_BUSY surfaced by EXCLUSIVE locking. Skip-gate via
|
|
504
|
+
// tmpdir-prefix check inside the helper — defaultDBPath() output
|
|
505
|
+
// (per-process tmp DBs) does not contend, so it never claims a lock.
|
|
506
|
+
acquireDbLock({ dbPath });
|
|
507
|
+
// v1.0.129 — single source of truth for skip decision. Both lockfile
|
|
508
|
+
// (above) and EXCLUSIVE pragma (below) MUST share the same skip-gate:
|
|
509
|
+
// tests open SessionDB twice on the same tmpdir path to exercise
|
|
510
|
+
// multi-writer scenarios; if EXCLUSIVE fires here when lockfile didn't,
|
|
511
|
+
// the second open hits SQLITE_BUSY on its FIRST pragma call inside
|
|
512
|
+
// applyWALPragmas — caused 82 test failures during v1.0.128 verification.
|
|
513
|
+
const skipExclusive = dbPath.startsWith(tmpdir());
|
|
473
514
|
cleanOrphanedWALFiles(dbPath);
|
|
474
515
|
let db;
|
|
475
516
|
try {
|
|
476
517
|
db = new Database(dbPath, { timeout: 30000 });
|
|
477
518
|
applyWALPragmas(db);
|
|
519
|
+
// v1.0.128 — Issue #560 SECONDARY defense for SQLiteBase callers (single-writer
|
|
520
|
+
// DBs like SessionDB). The .lock file (acquireDbLock above) is PRIMARY — it
|
|
521
|
+
// surfaces the conflicting PID with a clear UX message. EXCLUSIVE locking
|
|
522
|
+
// closes the narrow race window between lockfile claim + the actual
|
|
523
|
+
// `new Database(...)` open: a parallel process passing the lockfile
|
|
524
|
+
// check would still get SQLITE_BUSY from this pragma. Wrapped in try/catch —
|
|
525
|
+
// backends that don't expose locking_mode (or pragma at all) still get the
|
|
526
|
+
// lockfile floor. NOTE: NOT applied in `applyWALPragmas` because multi-writer
|
|
527
|
+
// callers (ContentStore — FTS5 shared knowledge base across sessions) rely
|
|
528
|
+
// on the default SHARED locking mode + withRetry to handle SQLITE_BUSY.
|
|
529
|
+
// Skip on tmpdir paths to match acquireDbLock's skip-gate.
|
|
530
|
+
if (!skipExclusive) {
|
|
531
|
+
try {
|
|
532
|
+
db.pragma("locking_mode = EXCLUSIVE");
|
|
533
|
+
}
|
|
534
|
+
catch { /* unsupported runtime */ }
|
|
535
|
+
}
|
|
478
536
|
}
|
|
479
537
|
catch (err) {
|
|
480
538
|
const msg = err instanceof Error ? err.message : String(err);
|
|
@@ -484,17 +542,27 @@ export class SQLiteBase {
|
|
|
484
542
|
try {
|
|
485
543
|
db = new Database(dbPath, { timeout: 30000 });
|
|
486
544
|
applyWALPragmas(db);
|
|
545
|
+
if (!skipExclusive) {
|
|
546
|
+
try {
|
|
547
|
+
db.pragma("locking_mode = EXCLUSIVE");
|
|
548
|
+
}
|
|
549
|
+
catch { /* unsupported runtime */ }
|
|
550
|
+
}
|
|
487
551
|
}
|
|
488
552
|
catch (retryErr) {
|
|
553
|
+
// Free the lock before bubbling — caller can never reach
|
|
554
|
+
// close()/cleanup() if the ctor throws.
|
|
555
|
+
releaseDbLock({ dbPath });
|
|
489
556
|
throw new Error(`Failed to create fresh DB after renaming corrupt file: ${retryErr instanceof Error ? retryErr.message : String(retryErr)}`);
|
|
490
557
|
}
|
|
491
558
|
}
|
|
492
559
|
else {
|
|
560
|
+
releaseDbLock({ dbPath });
|
|
493
561
|
throw err;
|
|
494
562
|
}
|
|
495
563
|
}
|
|
496
564
|
this.#db = db;
|
|
497
|
-
_liveDBs.
|
|
565
|
+
_liveDBs.set(this.#db, dbPath);
|
|
498
566
|
this.initSchema();
|
|
499
567
|
this.prepareStatements();
|
|
500
568
|
}
|
|
@@ -510,6 +578,10 @@ export class SQLiteBase {
|
|
|
510
578
|
close() {
|
|
511
579
|
_liveDBs.delete(this.#db);
|
|
512
580
|
closeDB(this.#db);
|
|
581
|
+
// v1.0.128 — Issue #560: drop the .lock file AFTER closeDB so the
|
|
582
|
+
// WAL checkpoint inside closeDB completes while we still own the
|
|
583
|
+
// writer slot. releaseDbLock is no-op for tmpdir paths (skip-gate).
|
|
584
|
+
releaseDbLock({ dbPath: this.#dbPath });
|
|
513
585
|
}
|
|
514
586
|
withRetry(fn) {
|
|
515
587
|
return withRetry(fn);
|
|
@@ -522,5 +594,9 @@ export class SQLiteBase {
|
|
|
522
594
|
_liveDBs.delete(this.#db);
|
|
523
595
|
closeDB(this.#db);
|
|
524
596
|
deleteDBFiles(this.#dbPath);
|
|
597
|
+
// v1.0.128 — Issue #560: also drop the lockfile during cleanup. Per-
|
|
598
|
+
// process tmp DBs (defaultDBPath()) skip-gate inside the helper, so
|
|
599
|
+
// this is only a side-effect for shared on-disk content stores.
|
|
600
|
+
releaseDbLock({ dbPath: this.#dbPath });
|
|
525
601
|
}
|
|
526
602
|
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* db-lock — Per-DB lockfile primitive for single-writer enforcement (#560).
|
|
3
|
+
*
|
|
4
|
+
* Issue #560: multiple context-mode MCP servers writing the same on-disk
|
|
5
|
+
* SQLite content store unbounded the WAL — readers held shared locks
|
|
6
|
+
* indefinitely so `wal_checkpoint(TRUNCATE)` never fired, and the only
|
|
7
|
+
* existing truncation path is `closeDB`'s checkpoint on graceful exit
|
|
8
|
+
* (which #559's zombie servers never reach). Result: 238MB+ WAL files
|
|
9
|
+
* and ctx_search hangs.
|
|
10
|
+
*
|
|
11
|
+
* This module provides a tiny atomic-write primitive sitting in front of
|
|
12
|
+
* `new Database(...)`. The first opener writes its PID into
|
|
13
|
+
* `<dbPath>.lock` via O_EXCL (`flag: 'wx'`). Subsequent openers either:
|
|
14
|
+
*
|
|
15
|
+
* - find the lockfile + see the PID is alive → throw
|
|
16
|
+
* DatabaseLockedError with the reporter's verbatim message;
|
|
17
|
+
* - find the lockfile + see the PID is dead → claim it, with a re-read
|
|
18
|
+
* check to resolve a same-instant race between two stale-claimers.
|
|
19
|
+
*
|
|
20
|
+
* The lockfile is the PRIMARY single-writer defense. The SQLiteBase ctor
|
|
21
|
+
* also applies `locking_mode = EXCLUSIVE` as a SECONDARY defense
|
|
22
|
+
* (belt-and-braces) — the lockfile owns the user-facing UX, EXCLUSIVE
|
|
23
|
+
* catches the narrow race window between the lockfile check and the
|
|
24
|
+
* actual `Database(...)` open.
|
|
25
|
+
*
|
|
26
|
+
* Per-process tmp DBs (those under `os.tmpdir()`) skip the lockfile
|
|
27
|
+
* entirely — those are the existing `defaultDBPath()` shape and embed
|
|
28
|
+
* `process.pid` already, so cross-instance contention is impossible.
|
|
29
|
+
*
|
|
30
|
+
* `isProcessAlive` is COPIED from `store.ts:187` — not imported — to
|
|
31
|
+
* keep `db-base.ts` (which imports this module) free of any dependency
|
|
32
|
+
* on `store.ts` (which itself imports from `db-base.ts`). See
|
|
33
|
+
* PR-559-560-FIX-DESIGN.md regression risks #4.
|
|
34
|
+
*/
|
|
35
|
+
/** User-facing failure used by SQLiteBase to surface the contention. */
|
|
36
|
+
export declare class DatabaseLockedError extends Error {
|
|
37
|
+
readonly pid: number;
|
|
38
|
+
readonly dbPath: string;
|
|
39
|
+
constructor(pid: number, dbPath: string);
|
|
40
|
+
}
|
|
41
|
+
export interface AcquireOptions {
|
|
42
|
+
dbPath: string;
|
|
43
|
+
}
|
|
44
|
+
export interface AcquireResult {
|
|
45
|
+
/** True when the lockfile was skipped because dbPath is under tmpdir. */
|
|
46
|
+
skipped: boolean;
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Atomically claim the lockfile for `dbPath`. Throws `DatabaseLockedError`
|
|
50
|
+
* if another live process holds it. Silently claims stale lockfiles whose
|
|
51
|
+
* owning PID is dead.
|
|
52
|
+
*/
|
|
53
|
+
export declare function acquireDbLock(opts: AcquireOptions): AcquireResult;
|
|
54
|
+
export interface ReleaseOptions {
|
|
55
|
+
dbPath: string;
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Drop the lockfile for `dbPath`. Swallows all errors so callers can
|
|
59
|
+
* always invoke this in a finally / cleanup path without try/catch —
|
|
60
|
+
* mirrors the shape of `db-base.ts closeDB`.
|
|
61
|
+
*
|
|
62
|
+
* Skipped (no-op) when `dbPath` is under tmpdir — symmetric with
|
|
63
|
+
* `acquireDbLock`'s skip-gate.
|
|
64
|
+
*/
|
|
65
|
+
export declare function releaseDbLock(opts: ReleaseOptions): void;
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* db-lock — Per-DB lockfile primitive for single-writer enforcement (#560).
|
|
3
|
+
*
|
|
4
|
+
* Issue #560: multiple context-mode MCP servers writing the same on-disk
|
|
5
|
+
* SQLite content store unbounded the WAL — readers held shared locks
|
|
6
|
+
* indefinitely so `wal_checkpoint(TRUNCATE)` never fired, and the only
|
|
7
|
+
* existing truncation path is `closeDB`'s checkpoint on graceful exit
|
|
8
|
+
* (which #559's zombie servers never reach). Result: 238MB+ WAL files
|
|
9
|
+
* and ctx_search hangs.
|
|
10
|
+
*
|
|
11
|
+
* This module provides a tiny atomic-write primitive sitting in front of
|
|
12
|
+
* `new Database(...)`. The first opener writes its PID into
|
|
13
|
+
* `<dbPath>.lock` via O_EXCL (`flag: 'wx'`). Subsequent openers either:
|
|
14
|
+
*
|
|
15
|
+
* - find the lockfile + see the PID is alive → throw
|
|
16
|
+
* DatabaseLockedError with the reporter's verbatim message;
|
|
17
|
+
* - find the lockfile + see the PID is dead → claim it, with a re-read
|
|
18
|
+
* check to resolve a same-instant race between two stale-claimers.
|
|
19
|
+
*
|
|
20
|
+
* The lockfile is the PRIMARY single-writer defense. The SQLiteBase ctor
|
|
21
|
+
* also applies `locking_mode = EXCLUSIVE` as a SECONDARY defense
|
|
22
|
+
* (belt-and-braces) — the lockfile owns the user-facing UX, EXCLUSIVE
|
|
23
|
+
* catches the narrow race window between the lockfile check and the
|
|
24
|
+
* actual `Database(...)` open.
|
|
25
|
+
*
|
|
26
|
+
* Per-process tmp DBs (those under `os.tmpdir()`) skip the lockfile
|
|
27
|
+
* entirely — those are the existing `defaultDBPath()` shape and embed
|
|
28
|
+
* `process.pid` already, so cross-instance contention is impossible.
|
|
29
|
+
*
|
|
30
|
+
* `isProcessAlive` is COPIED from `store.ts:187` — not imported — to
|
|
31
|
+
* keep `db-base.ts` (which imports this module) free of any dependency
|
|
32
|
+
* on `store.ts` (which itself imports from `db-base.ts`). See
|
|
33
|
+
* PR-559-560-FIX-DESIGN.md regression risks #4.
|
|
34
|
+
*/
|
|
35
|
+
import { writeFileSync, readFileSync, unlinkSync } from "node:fs";
|
|
36
|
+
import { tmpdir } from "node:os";
|
|
37
|
+
/** User-facing failure used by SQLiteBase to surface the contention. */
|
|
38
|
+
export class DatabaseLockedError extends Error {
|
|
39
|
+
pid;
|
|
40
|
+
dbPath;
|
|
41
|
+
constructor(pid, dbPath) {
|
|
42
|
+
super(`Another context-mode server is already running (PID: ${pid}). ` +
|
|
43
|
+
`Stop it before starting a new instance.`);
|
|
44
|
+
this.name = "DatabaseLockedError";
|
|
45
|
+
this.pid = pid;
|
|
46
|
+
this.dbPath = dbPath;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Liveness probe — a 6-line copy of `store.ts:187 isProcessAlive`.
|
|
51
|
+
* Sends signal 0 (no-op kill) which only verifies that the kernel
|
|
52
|
+
* recognizes the PID + that the caller has permission to signal it.
|
|
53
|
+
*
|
|
54
|
+
* Copied (not imported) so this module stays leaf-level and `db-base.ts`
|
|
55
|
+
* does not pick up a transitive dependency on `store.ts` — `store.ts`
|
|
56
|
+
* already imports from `db-base.ts`, so the reverse would create a
|
|
57
|
+
* circular dep that breaks under bun:sqlite's lazy load path.
|
|
58
|
+
*/
|
|
59
|
+
function isProcessAlive(pid) {
|
|
60
|
+
try {
|
|
61
|
+
process.kill(pid, 0);
|
|
62
|
+
return true;
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
return false;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
function lockPathFor(dbPath) {
|
|
69
|
+
return `${dbPath}.lock`;
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* tmpdir skip-gate — per-process DBs (e.g. defaultDBPath() output) embed
|
|
73
|
+
* `process.pid` so cross-instance contention is impossible by
|
|
74
|
+
* construction. We never want to install a lockfile on the test runner's
|
|
75
|
+
* tmp scratch path either.
|
|
76
|
+
*/
|
|
77
|
+
function isUnderTmpdir(dbPath) {
|
|
78
|
+
// Trailing-slash normalize — tmpdir() may or may not include it on the
|
|
79
|
+
// current platform, and dbPath may be exactly tmpdir() when callers
|
|
80
|
+
// join() with no separator (rare but cheap to guard).
|
|
81
|
+
const tmp = tmpdir();
|
|
82
|
+
return dbPath === tmp || dbPath.startsWith(tmp + "/") || dbPath.startsWith(tmp + "\\");
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Atomically claim the lockfile for `dbPath`. Throws `DatabaseLockedError`
|
|
86
|
+
* if another live process holds it. Silently claims stale lockfiles whose
|
|
87
|
+
* owning PID is dead.
|
|
88
|
+
*/
|
|
89
|
+
export function acquireDbLock(opts) {
|
|
90
|
+
const { dbPath } = opts;
|
|
91
|
+
if (isUnderTmpdir(dbPath))
|
|
92
|
+
return { skipped: true };
|
|
93
|
+
const lockPath = lockPathFor(dbPath);
|
|
94
|
+
const ownPid = String(process.pid);
|
|
95
|
+
// Fast path: O_EXCL atomic create — succeeds iff the lockfile did not
|
|
96
|
+
// exist. This is the single race-free moment that grants ownership.
|
|
97
|
+
try {
|
|
98
|
+
writeFileSync(lockPath, ownPid, { flag: "wx" });
|
|
99
|
+
return { skipped: false };
|
|
100
|
+
}
|
|
101
|
+
catch (err) {
|
|
102
|
+
const code = err?.code;
|
|
103
|
+
if (code !== "EEXIST")
|
|
104
|
+
throw err;
|
|
105
|
+
// Fall through to liveness check.
|
|
106
|
+
}
|
|
107
|
+
// Slow path: lockfile exists. Read the PID, probe liveness.
|
|
108
|
+
let existingPidStr;
|
|
109
|
+
try {
|
|
110
|
+
existingPidStr = readFileSync(lockPath, "utf-8").trim();
|
|
111
|
+
}
|
|
112
|
+
catch {
|
|
113
|
+
// Lockfile vanished between EEXIST and read — race won by another
|
|
114
|
+
// claimer that already finished cleanup. Retry once via the fast
|
|
115
|
+
// path; if even that fails, surface as locked (best-effort).
|
|
116
|
+
try {
|
|
117
|
+
writeFileSync(lockPath, ownPid, { flag: "wx" });
|
|
118
|
+
return { skipped: false };
|
|
119
|
+
}
|
|
120
|
+
catch {
|
|
121
|
+
throw new DatabaseLockedError(0, dbPath);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
const existingPid = Number.parseInt(existingPidStr, 10);
|
|
125
|
+
if (Number.isFinite(existingPid) && existingPid > 0 && isProcessAlive(existingPid)) {
|
|
126
|
+
throw new DatabaseLockedError(existingPid, dbPath);
|
|
127
|
+
}
|
|
128
|
+
// Stale lockfile — owning PID is dead (or unparseable). Claim it.
|
|
129
|
+
// We do NOT use { flag: 'wx' } here because we deliberately want to
|
|
130
|
+
// overwrite the dead-PID record. Then re-read to confirm we won the
|
|
131
|
+
// race against any other process also seeing the same stale lock.
|
|
132
|
+
writeFileSync(lockPath, ownPid, { flag: "w" });
|
|
133
|
+
let writtenPid;
|
|
134
|
+
try {
|
|
135
|
+
writtenPid = Number.parseInt(readFileSync(lockPath, "utf-8").trim(), 10);
|
|
136
|
+
}
|
|
137
|
+
catch {
|
|
138
|
+
// Vanished again — extremely unlikely. Surface as locked rather than
|
|
139
|
+
// proceeding with no guarantee.
|
|
140
|
+
throw new DatabaseLockedError(0, dbPath);
|
|
141
|
+
}
|
|
142
|
+
if (writtenPid !== process.pid) {
|
|
143
|
+
// Lost the stale-claim race to another concurrent claimer.
|
|
144
|
+
throw new DatabaseLockedError(writtenPid, dbPath);
|
|
145
|
+
}
|
|
146
|
+
return { skipped: false };
|
|
147
|
+
}
|
|
148
|
+
/**
|
|
149
|
+
* Drop the lockfile for `dbPath`. Swallows all errors so callers can
|
|
150
|
+
* always invoke this in a finally / cleanup path without try/catch —
|
|
151
|
+
* mirrors the shape of `db-base.ts closeDB`.
|
|
152
|
+
*
|
|
153
|
+
* Skipped (no-op) when `dbPath` is under tmpdir — symmetric with
|
|
154
|
+
* `acquireDbLock`'s skip-gate.
|
|
155
|
+
*/
|
|
156
|
+
export function releaseDbLock(opts) {
|
|
157
|
+
const { dbPath } = opts;
|
|
158
|
+
if (isUnderTmpdir(dbPath))
|
|
159
|
+
return;
|
|
160
|
+
try {
|
|
161
|
+
unlinkSync(lockPathFor(dbPath));
|
|
162
|
+
}
|
|
163
|
+
catch {
|
|
164
|
+
// Already gone, permission denied, etc. — best-effort.
|
|
165
|
+
}
|
|
166
|
+
}
|