claude-mem-lite 6.25.2 → 6.26.0

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/lib/proc-lock.mjs CHANGED
@@ -16,7 +16,18 @@
16
16
  // already doing the write). Release unlinks the file. Crash-safe: a crashed holder's
17
17
  // pid is gone, so the next session reclaims its lock at once.
18
18
 
19
- import { writeFileSync, readFileSync, unlinkSync, mkdirSync, renameSync, linkSync, lstatSync } from 'node:fs';
19
+ import {
20
+ writeFileSync,
21
+ readFileSync,
22
+ unlinkSync,
23
+ mkdirSync,
24
+ renameSync,
25
+ linkSync,
26
+ lstatSync,
27
+ statSync,
28
+ accessSync,
29
+ constants as fsConstants,
30
+ } from 'node:fs';
20
31
  import { dirname } from 'node:path';
21
32
  import { workerDirGone } from './worker-data-dir.mjs';
22
33
 
@@ -75,13 +86,21 @@ function isStale(lockPath, staleMs, now) {
75
86
  * temp carries a `.lock` suffix so hook.mjs's sweeper reclaims it if we die in between.
76
87
  * Filesystems without hard links (some network / FAT mounts) fall back to the old form —
77
88
  * they keep the narrow window, which is still better than never acquiring a lock at all.
89
+ *
90
+ * A failure is returned as the errno, so a caller can say why the lock cannot be taken.
78
91
  */
79
92
  function createExclusive(lockPath, payload) {
80
93
  const tmp = `${lockPath}.new-${process.pid}-${Math.random().toString(36).slice(2)}.lock`;
81
94
  try {
82
95
  writeFileSync(tmp, payload, { flag: 'wx' });
83
- } catch {
84
- return 'error';
96
+ } catch (e) {
97
+ // open() may have created it before the write failed (a full disk): do not leave it behind.
98
+ try {
99
+ unlinkSync(tmp);
100
+ } catch {
101
+ /* never created */
102
+ }
103
+ return { error: e.code || 'EIO' };
85
104
  }
86
105
  try {
87
106
  linkSync(tmp, lockPath);
@@ -93,7 +112,7 @@ function createExclusive(lockPath, payload) {
93
112
  writeFileSync(lockPath, payload, { flag: 'wx' });
94
113
  return 'ok';
95
114
  } catch (e2) {
96
- return e2.code === 'EEXIST' ? 'exists' : 'error';
115
+ return e2.code === 'EEXIST' ? 'exists' : { error: e2.code || 'EIO' };
97
116
  }
98
117
  } finally {
99
118
  try {
@@ -131,10 +150,29 @@ function makeRelease(lockPath, payload) {
131
150
  * @returns {(() => void)|null} A release() fn, or null if a live peer holds it, the lock file
132
151
  * could not be created, or this is a background worker whose lock directory was removed.
133
152
  */
134
- export function acquireLock(lockPath, { staleMs = DEFAULT_STALE_MS, now = Date.now } = {}) {
153
+ export function acquireLock(lockPath, opts) {
154
+ return takeLock(lockPath, opts).release || null;
155
+ }
156
+
157
+ /**
158
+ * acquireLock, saying why when it does not take the lock. acquireLock's null stands both for a
159
+ * live holder and for a lock file that cannot be created at all, and every installer read that
160
+ * null as "another install is in progress": with a runtime dir a `sudo` run left root-owned, a
161
+ * read-only data dir, a runtime link to nowhere or a full disk, every update skipped silently and
162
+ * install exited 0 having done nothing (D#304, D#307). An error is reported for every failed
163
+ * create: as lockDirBlocked names it when it sees the cause, otherwise with the create's own errno,
164
+ * which covers the causes its access() check cannot see (a Windows ACL, ENOSPC).
165
+ * @returns {{release: () => void} | {error: {dir: string, code: string}} | {}} {} when a live peer
166
+ * holds it, or this is a background worker whose lock directory was removed.
167
+ */
168
+ export function takeLock(lockPath, { staleMs = DEFAULT_STALE_MS, now = Date.now } = {}) {
169
+ // lockDirBlocked, when it sees the cause, names the directory that has to change and why: it walks
170
+ // to an ancestor that cannot be entered, a link to nowhere, a component that is a file. A cause it
171
+ // cannot see (a full disk, a Windows ACL access() does not read) keeps the create's own errno.
172
+ const failed = (code) => ({ error: lockDirBlocked(lockPath) || { dir: dirname(lockPath), code } });
135
173
  // A background worker (auto-maintain takes its lock in the runtime dir) does not re-create a
136
174
  // directory removed while it ran (D#265). Not taking the lock is the caller's no-op path.
137
- if (workerDirGone(dirname(lockPath))) return null;
175
+ if (workerDirGone(dirname(lockPath))) return {};
138
176
  try {
139
177
  mkdirSync(dirname(lockPath), { recursive: true });
140
178
  } catch {
@@ -150,8 +188,8 @@ export function acquireLock(lockPath, { staleMs = DEFAULT_STALE_MS, now = Date.n
150
188
  nonce: Math.random().toString(36).slice(2),
151
189
  });
152
190
  const first = createExclusive(lockPath, payload);
153
- if (first === 'ok') return makeRelease(lockPath, payload);
154
- if (first === 'error') return null; // permission / fs error → fail closed
191
+ if (first === 'ok') return { release: makeRelease(lockPath, payload) };
192
+ if (first.error) return failed(first.error); // permission / fs error → fail closed, and say why
155
193
  {
156
194
  let sampled;
157
195
  try {
@@ -162,9 +200,11 @@ export function acquireLock(lockPath, { staleMs = DEFAULT_STALE_MS, now = Date.n
162
200
  // it would move a peer's freshly created lock aside with nothing to compare it to,
163
201
  // which is the double-acquire this whole protocol exists to prevent. Just retry the
164
202
  // plain exclusive create once and take the answer.
165
- return createExclusive(lockPath, payload) === 'ok' ? makeRelease(lockPath, payload) : null;
203
+ const again = createExclusive(lockPath, payload);
204
+ if (again === 'ok') return { release: makeRelease(lockPath, payload) };
205
+ return again.error ? failed(again.error) : {};
166
206
  }
167
- if (!isStale(lockPath, staleMs, now)) return null; // live peer holds it
207
+ if (!isStale(lockPath, staleMs, now)) return {}; // live peer holds it
168
208
  // ── Stealing a stale lock (R10 P1-7) ──────────────────────────────────────────────
169
209
  // This used to be unlink-then-create, and the comment "lose the race → null" was only
170
210
  // true of the create. The losing interleave: A unlinks, A creates, B unlinks A's BRAND
@@ -182,7 +222,7 @@ export function acquireLock(lockPath, { staleMs = DEFAULT_STALE_MS, now = Date.n
182
222
  try {
183
223
  renameSync(lockPath, tombstone);
184
224
  } catch {
185
- return null; // another stealer won the rename; it owns the re-create
225
+ return {}; // another stealer won the rename; it owns the re-create
186
226
  }
187
227
  let stolen = null;
188
228
  try {
@@ -201,14 +241,16 @@ export function acquireLock(lockPath, { staleMs = DEFAULT_STALE_MS, now = Date.n
201
241
  } catch {
202
242
  /* best-effort */
203
243
  }
204
- return null;
244
+ return {};
205
245
  }
206
246
  try {
207
247
  unlinkSync(tombstone);
208
248
  } catch {
209
249
  /* best-effort — the .lock suffix keeps it sweepable either way */
210
250
  }
211
- return createExclusive(lockPath, payload) === 'ok' ? makeRelease(lockPath, payload) : null;
251
+ const retaken = createExclusive(lockPath, payload);
252
+ if (retaken === 'ok') return { release: makeRelease(lockPath, payload) };
253
+ return retaken.error ? failed(retaken.error) : {};
212
254
  }
213
255
  }
214
256
 
@@ -235,6 +277,87 @@ export function lockHeld(lockPath, { staleMs = DEFAULT_STALE_MS, now = Date.now
235
277
  return !isStale(lockPath, staleMs, now);
236
278
  }
237
279
 
280
+ /**
281
+ * Why acquireLock cannot create `lockPath` whoever holds it, or null when it can. acquireLock
282
+ * answers null both for a live holder and for a lock file it cannot create at all, and every
283
+ * caller read that null as "another install is in progress": with a runtime dir a `sudo` run left
284
+ * root-owned, or a read-only data dir, every update skipped silently, install exited 0 having done
285
+ * nothing, and cleanup named a lock that did not exist (D#304, D#307). This tells the two apart.
286
+ * Read-only, like lockHeld: it creates nothing.
287
+ *
288
+ * The directory asked about is the lock's own, or, when that does not exist yet, the nearest one
289
+ * that does: acquireLock creates the rest, so that is the one it has to write into.
290
+ * @returns {{dir: string, code: string}|null}
291
+ */
292
+ export function lockDirBlocked(lockPath) {
293
+ let dir = dirname(lockPath);
294
+ for (;;) {
295
+ // A path that is there but is a file, not a directory: access() reads the file's own mode bits
296
+ // (EACCES, or a pass for a 0777 file), so it is asked first.
297
+ try {
298
+ if (!statSync(dir).isDirectory()) return { dir, code: 'ENOTDIR' };
299
+ } catch {
300
+ /* absent, or not reachable: access() below says which */
301
+ }
302
+ try {
303
+ accessSync(dir, fsConstants.W_OK | fsConstants.X_OK);
304
+ return null;
305
+ } catch (e) {
306
+ // A link to nowhere reads as absent, but nothing can be created under it (review P2-1).
307
+ if (e.code === 'ENOENT' && isLink(dir)) return { dir, code: 'ENOENT' };
308
+ // A component that is a file, not a directory: name that file.
309
+ if (e.code === 'ENOTDIR') return { dir: firstNonDirectory(dir) ?? dir, code: 'ENOTDIR' };
310
+ // A directory under one that cannot be entered fails EACCES too: name the one to fix.
311
+ if (e.code !== 'ENOENT') return unenterableAncestor(dir) || { dir, code: e.code || 'EACCES' };
312
+ const up = dirname(dir);
313
+ if (up === dir) return null;
314
+ dir = up;
315
+ }
316
+ }
317
+ }
318
+
319
+ function isLink(p) {
320
+ try {
321
+ return lstatSync(p).isSymbolicLink();
322
+ } catch {
323
+ return false;
324
+ }
325
+ }
326
+
327
+ /** The component of `dir` nearest the root that exists and is not a directory, or null. */
328
+ function firstNonDirectory(dir) {
329
+ const chain = [];
330
+ for (let d = dir; ; d = dirname(d)) {
331
+ chain.unshift(d);
332
+ if (dirname(d) === d) break;
333
+ }
334
+ for (const d of chain) {
335
+ try {
336
+ if (!statSync(d).isDirectory()) return d;
337
+ } catch {
338
+ return null;
339
+ }
340
+ }
341
+ return null;
342
+ }
343
+
344
+ /** The ancestor of `dir` nearest the root that this user cannot enter, or null. */
345
+ function unenterableAncestor(dir) {
346
+ const chain = [];
347
+ for (let d = dirname(dir); ; d = dirname(d)) {
348
+ chain.unshift(d);
349
+ if (dirname(d) === d) break;
350
+ }
351
+ for (const d of chain) {
352
+ try {
353
+ accessSync(d, fsConstants.X_OK);
354
+ } catch (e) {
355
+ return e.code === 'ENOENT' ? null : { dir: d, code: e.code || 'EACCES' };
356
+ }
357
+ }
358
+ return null;
359
+ }
360
+
238
361
  /**
239
362
  * Run `fn` while holding the lock; release in a finally. No-op if not acquired.
240
363
  * @returns {{acquired: boolean, result?: any}}
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "claude-mem-lite",
3
- "version": "6.25.2",
3
+ "version": "6.26.0",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "claude-mem-lite",
9
- "version": "6.25.2",
9
+ "version": "6.26.0",
10
10
  "os": [
11
11
  "darwin",
12
12
  "linux",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-mem-lite",
3
- "version": "6.25.2",
3
+ "version": "6.26.0",
4
4
  "description": "Persistent long-term memory for Claude Code via MCP — captures coding decisions, bugfixes, and context across sessions. FTS5 BM25 keyword search with episode batching. Single SQLite DB, no external services. A lighter, lower-cost alternative to claude-mem (episode batching + a smaller model; cost savings are an internal estimate, not a measured benchmark).",
5
5
  "type": "module",
6
6
  "packageManager": "npm@10.9.2",
@@ -114,11 +114,14 @@ try {
114
114
  process.stderr.write(`[claude-mem-lite] binding probe: ${e.message}\n`);
115
115
  process.exit(1);
116
116
  }
117
- const release = helpers.acquireLock(lockPath);
117
+ const { release, error: blocked } = helpers.takeLock(lockPath);
118
118
  if (!release) {
119
+ // A lock that cannot be created at all is not an install in flight (D#307): name it, as doctor does.
119
120
  process.stderr.write(
120
121
  `[claude-mem-lite] binding probe: ${helpers.flattenBindingError(first.error)} ` +
121
- '(another install/repair in flight — deferring heal)\n',
122
+ (blocked
123
+ ? `(install.lock cannot be taken — ${blocked.dir} cannot be written (${blocked.code}); run doctor for the fix)\n`
124
+ : '(another install/repair in flight — deferring heal)\n'),
122
125
  );
123
126
  process.exit(1);
124
127
  }
@@ -497,9 +497,7 @@ function healNativeBindingIfBroken() {
497
497
  // (install.mjs::rebuildBinding). Clearing it here would mean a rebuild that
498
498
  // silently did nothing — lock contention, a no-op npm — still reads as
499
499
  // "healed", dropping the cooldown and re-spawning npm on every session.
500
- process.stderr.write(
501
- '[claude-mem-lite] native DB binding unusable (Node version change?) — rebuilding in the background\n',
502
- );
500
+ process.stderr.write('[claude-mem-lite] native DB binding unusable — rebuilding in the background\n');
503
501
  const child = spawn(process.execPath, [installer, 'rebuild-binding'], {
504
502
  detached: true,
505
503
  stdio: 'ignore',
@@ -116,11 +116,19 @@ try {
116
116
  // mid-compile. Take the lock for the rebuild-capable path; a live peer →
117
117
  // wait up to 10s, then degrade to a probe-only pass (healthy binding
118
118
  // proceeds; a broken one defers to the peer instead of racing it).
119
- const { acquireLock } = await import('../lib/proc-lock.mjs');
119
+ const { takeLock } = await import('../lib/proc-lock.mjs');
120
120
  const { resolveDataDir } = await import('../lib/resolve-data-dir.mjs');
121
121
  const lockPath = join(resolveDataDir(process.env.CLAUDE_MEM_DIR), 'runtime', 'install.lock'); // runtime-dir:stays-put — install lock serialises real installers
122
+ // A lock that cannot be created at all (a runtime dir a sudo run left root-owned, a link to
123
+ // nowhere, a full disk) is not a peer to wait for: nothing ever releases it, and waiting cost
124
+ // every MCP launch 10 s (D#307). takeLock says which it is.
122
125
  let release = null;
123
- for (let i = 0; i < 20 && !(release = acquireLock(lockPath)); i++) {
126
+ let blocked = null;
127
+ for (let i = 0; i < 20; i++) {
128
+ const taken = takeLock(lockPath);
129
+ release = taken.release || null;
130
+ blocked = taken.error || null;
131
+ if (release || blocked) break;
124
132
  await new Promise((r) => setTimeout(r, 500));
125
133
  }
126
134
  let verify;
@@ -137,7 +145,9 @@ try {
137
145
  ? { ok: true, action: 'verified' }
138
146
  : {
139
147
  ok: false,
140
- error: `${probe.error} (another install/repair holds the lock — not rebuilding concurrently; reconnect with /mcp once it finishes)`,
148
+ error: blocked
149
+ ? `${probe.error} (install.lock cannot be taken — ${blocked.dir} cannot be written (${blocked.code}), so it was not rebuilt; \`doctor\` names the fix)`
150
+ : `${probe.error} (another install/repair holds the lock — not rebuilding concurrently; reconnect with /mcp once it finishes)`,
141
151
  };
142
152
  }
143
153
  } finally {