@davesheffer/hunch 1.32.4 → 1.32.7

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.
@@ -4,10 +4,12 @@
4
4
  * authoritative read/write surface; SQLite is rebuilt from it.
5
5
  */
6
6
  import { closeSync, constants, fstatSync, lstatSync, mkdirSync, openSync, opendirSync, readSync, realpathSync, rmSync, } from "node:fs";
7
+ import { hostname } from "node:os";
7
8
  import { isAbsolute, join, relative, resolve, sep } from "node:path";
8
9
  import { ENTITY_KINDS, SCHEMAS } from "../core/types.js";
9
10
  import { BASELINE_VERSION, migrateRaw, SCHEMA_VERSION } from "../core/migrate.js";
10
11
  import { writeFileAtomic } from "../core/io.js";
12
+ import { readStoreArtifact } from "../core/storeArtifact.js";
11
13
  /** High-cardinality collections (symbols, edges) are stored as a single
12
14
  * index.json array — there can be thousands, and one file per edge would create
13
15
  * enormous git noise. Curated, low-volume entities (components, decisions, bugs,
@@ -35,6 +37,29 @@ export const MAX_JSON_RECORD_BYTES = 8 * 1024 * 1024;
35
37
  export const MAX_JSON_INDEX_BYTES = 256 * 1024 * 1024;
36
38
  export const MAX_JSON_MANIFEST_BYTES = 64 * 1024;
37
39
  export const MAX_JSON_DIRECTORY_ENTRIES_PER_KIND = 100_000;
40
+ function readRmwOwner(lock) {
41
+ const text = readStoreArtifact(lock, ["owner.tmp.json"], 4096);
42
+ if (text === null)
43
+ return undefined;
44
+ try {
45
+ const parsed = JSON.parse(text);
46
+ if (typeof parsed.pid !== "number" || !Number.isInteger(parsed.pid) || parsed.pid < 1 || typeof parsed.host !== "string")
47
+ return undefined;
48
+ return { pid: parsed.pid, host: parsed.host };
49
+ }
50
+ catch {
51
+ return undefined;
52
+ }
53
+ }
54
+ function rmwPidAlive(pid) {
55
+ try {
56
+ process.kill(pid, 0);
57
+ return true;
58
+ }
59
+ catch (error) {
60
+ return error.code === "EPERM";
61
+ }
62
+ }
38
63
  function missing(error) {
39
64
  return error.code === "ENOENT";
40
65
  }
@@ -431,30 +456,51 @@ export class JsonStore {
431
456
  * two unsynchronized RMWs over index.json each read the same base array and the
432
457
  * second rename silently erases the first's record. `mkdirSync` is the atomic
433
458
  * acquire (EEXIST = held). A stale lock (killed process) is taken over by age;
434
- * against a live contender we wait briefly and then proceed WITH a warning —
435
- * never worse than the historical lockless behavior, and capture paths must not
436
- * start throwing on lock contention. */
459
+ * against a live contender we wait briefly and then refuse the write. Proceeding
460
+ * without the lock would reintroduce the record-loss race this mutex exists to
461
+ * prevent. */
437
462
  withSingleFileLock(kind, directory, fn) {
438
463
  const lock = join(directory.lexical, ".rmw-lock");
439
464
  const deadline = Date.now() + 2_000;
440
465
  for (;;) {
466
+ if (Date.now() >= deadline)
467
+ throw new Error(`[hunch] timed out acquiring the ${kind} index lock (still held: ${lock})`);
441
468
  try {
442
469
  mkdirSync(lock);
470
+ // Record ownership inside the already-exclusive directory. A live local
471
+ // writer may exceed the stale-age heuristic while serializing a large
472
+ // index; its PID must prevent a second writer from taking over.
473
+ try {
474
+ writeFileAtomic(join(lock, "owner.tmp.json"), JSON.stringify({ pid: process.pid, host: hostname() }));
475
+ }
476
+ catch (error) {
477
+ try {
478
+ rmSync(lock, { recursive: true, force: true });
479
+ }
480
+ catch { /* report the ownership failure below */ }
481
+ throw new Error(`[hunch] could not record ownership for the ${kind} index lock: ${error.message}`, { cause: error });
482
+ }
443
483
  break;
444
484
  }
445
- catch {
485
+ catch (error) {
486
+ if (error.code !== "EEXIST")
487
+ throw error;
488
+ let stat;
446
489
  try {
447
- if (Date.now() - lstatSync(lock).mtimeMs > 10_000) {
448
- rmSync(lock, { recursive: true, force: true }); // no live spawn holds a lock this old
449
- continue;
450
- }
490
+ stat = lstatSync(lock);
451
491
  }
452
- catch {
453
- continue; /* vanished between attempts — retry the acquire */
492
+ catch (statError) {
493
+ if (statError.code === "ENOENT")
494
+ continue; // vanished between mkdir and inspect
495
+ throw statError;
454
496
  }
455
- if (Date.now() >= deadline) {
456
- console.warn(`[hunch] proceeding without the ${kind} index lock (still held: ${lock})`);
457
- return fn();
497
+ const owner = readRmwOwner(lock);
498
+ const stale = owner && owner.host === hostname()
499
+ ? !rmwPidAlive(owner.pid)
500
+ : Date.now() - stat.mtimeMs > 10_000;
501
+ if (stale) {
502
+ rmSync(lock, { recursive: true, force: true });
503
+ continue;
458
504
  }
459
505
  Atomics.wait(RMW_LOCK_WAITER, 0, 0, 25);
460
506
  }
@@ -512,7 +558,12 @@ export class JsonStore {
512
558
  // Sorted by id so the index has ONE canonical order — re-indexing after a
513
559
  // git merge (which the driver also id-sorts) doesn't churn the whole file.
514
560
  validated.sort((a, b) => String(a.id).localeCompare(String(b.id)));
515
- this.writeContainedFile(directory, this.fileFor(kind, "index"), encode(validated), this.maxBytes(kind));
561
+ // A rebuild is also a read-modify-write boundary from the perspective of
562
+ // concurrent put/delete callers: without the same mutex it can publish
563
+ // over an update that acquired the lock moments earlier (or vice versa).
564
+ this.withSingleFileLock(kind, directory, () => {
565
+ this.writeContainedFile(directory, this.fileFor(kind, "index"), encode(validated), this.maxBytes(kind));
566
+ });
516
567
  return;
517
568
  }
518
569
  // One file per record: preflight EVERY existing JSON file before touching
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@davesheffer/hunch",
3
- "version": "1.32.4",
3
+ "version": "1.32.7",
4
4
  "mcpName": "io.github.davesheffer/hunch",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Dave Sheffer <dave.sheffer1@gmail.com>",
package/server.json CHANGED
@@ -7,13 +7,13 @@
7
7
  "source": "github"
8
8
  },
9
9
  "websiteUrl": "https://www.hunchmemory.com",
10
- "version": "1.32.4",
10
+ "version": "1.32.7",
11
11
  "packages": [
12
12
  {
13
13
  "registryType": "npm",
14
14
  "registryBaseUrl": "https://registry.npmjs.org",
15
15
  "identifier": "@davesheffer/hunch",
16
- "version": "1.32.4",
16
+ "version": "1.32.7",
17
17
  "runtimeHint": "npx",
18
18
  "packageArguments": [
19
19
  {