@hviana/sema 0.7.1 → 0.7.3

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/src/store.ts CHANGED
@@ -1133,9 +1133,6 @@ export abstract class AbstractStore implements Store {
1133
1133
  return rec;
1134
1134
  }
1135
1135
 
1136
- /** Reconstruct the bytes a node spans by traversing the DAG bottom-up.
1137
- * Iterative post-order on an explicit stack — the call stack never sees the
1138
- * tree depth, so even an adversarial chain of nodes stays safe. */
1139
1136
  /** How many reads hit a MISSING node record this session (a dangling edge
1140
1137
  * or kid id). Zero in a healthy store; a growing count means references
1141
1138
  * outlive their records — the read degrades safely to empty bytes, this
@@ -1147,6 +1144,25 @@ export abstract class AbstractStore implements Store {
1147
1144
  * unprofiled store pays one null check per read and allocates nothing. */
1148
1145
  meter: Meter | null = null;
1149
1146
 
1147
+ /** Reconstruct the bytes a node spans by traversing the DAG bottom-up.
1148
+ * Iterative post-order on an explicit stack — the call stack never sees the
1149
+ * tree depth, so even an adversarial chain of nodes stays safe.
1150
+ *
1151
+ * TERMINATION. The walk memoizes into a LOCAL map, and `_bytesCache` is
1152
+ * consulted only as a warm hint whose hit is immediately promoted into that
1153
+ * map. It used to use `_bytesCache` itself as the memo, which is not a
1154
+ * memo at all: it EVICTS, and its `"smallest"` policy prefers precisely the
1155
+ * freshly-resolved small children that the pending parents on the stack are
1156
+ * waiting for. A parent then finds them uncached again, re-pushes them,
1157
+ * they are re-resolved, re-inserted, re-evicted — the loop makes no
1158
+ * progress and never exits. Latent until the cache saturates, then
1159
+ * unconditional: observed in the wild at 19.9M nodes with the 20 MB cache
1160
+ * pinned at 19,999,962/20,000,000 bytes, spinning 8h45m on a node whose
1161
+ * whole content was 124 bytes (5 kids, 2 of them perpetually re-evicted).
1162
+ * Because the loop is synchronous, no timer could fire — the trainer's stall
1163
+ * watchdog never got a turn either. A local map resolves each node at most
1164
+ * once per call, so the walk terminates by construction and `_bytesCache`
1165
+ * goes back to being a pure speed hint. */
1150
1166
  bytes(id: NodeId): Uint8Array {
1151
1167
  if (this.meter) {
1152
1168
  this.meter.byteReads++;
@@ -1162,12 +1178,25 @@ export abstract class AbstractStore implements Store {
1162
1178
 
1163
1179
  const stack: NodeId[] = [id];
1164
1180
  const cache = this._bytesCache;
1181
+ // The walk's own memo. Entries are the same shared arrays `_bytesCache`
1182
+ // holds (no extra copy), and it lives exactly as long as this call.
1183
+ const done = new Map<NodeId, Uint8Array>();
1165
1184
 
1166
1185
  while (stack.length > 0) {
1167
1186
  const nid = stack[stack.length - 1]; // peek
1168
1187
 
1169
- // Already resolved by an earlier traversal.
1170
- if (cache.get(nid)) {
1188
+ // Already resolved by this walk — the ONLY authority the readiness test
1189
+ // below trusts, because it cannot be evicted underneath us.
1190
+ if (done.has(nid)) {
1191
+ stack.pop();
1192
+ continue;
1193
+ }
1194
+
1195
+ // Warm hint: a hit is promoted into `done` in the same step, so from
1196
+ // here on the entry is pinned for the rest of the walk.
1197
+ const warm = cache.get(nid);
1198
+ if (warm !== undefined) {
1199
+ done.set(nid, warm);
1171
1200
  stack.pop();
1172
1201
  continue;
1173
1202
  }
@@ -1180,22 +1209,27 @@ export abstract class AbstractStore implements Store {
1180
1209
  // The cache makes the empty read permanent for the session; the
1181
1210
  // counter survives as the visible trace.
1182
1211
  this.danglingReads++;
1212
+ done.set(nid, _ZERO);
1183
1213
  cache.set(nid, _ZERO);
1184
1214
  stack.pop();
1185
1215
  continue;
1186
1216
  }
1187
1217
  if (rec.leaf) {
1188
- cache.set(nid, new Uint8Array(rec.leaf));
1218
+ // COPY before caching: rec.leaf is the node record's own buffer, and
1219
+ // handing it out would let one mutating caller corrupt the record.
1220
+ const leaf = new Uint8Array(rec.leaf);
1221
+ done.set(nid, leaf);
1222
+ cache.set(nid, leaf);
1189
1223
  stack.pop();
1190
1224
  continue;
1191
1225
  }
1192
1226
 
1193
- // Branch — push any uncached children (reverse order so they resolve
1194
- // left-to-right). If every child is already cached, concatenate now.
1227
+ // Branch — push any unresolved children (reverse order so they resolve
1228
+ // left-to-right). If every child is resolved, concatenate now.
1195
1229
  const kids = rec.kids ?? [];
1196
1230
  let ready = true;
1197
1231
  for (let i = kids.length - 1; i >= 0; i--) {
1198
- if (!cache.get(kids[i])) {
1232
+ if (!done.has(kids[i])) {
1199
1233
  stack.push(kids[i]);
1200
1234
  ready = false;
1201
1235
  }
@@ -1203,11 +1237,12 @@ export abstract class AbstractStore implements Store {
1203
1237
  if (!ready) continue;
1204
1238
 
1205
1239
  stack.pop();
1206
- const out = concat(kids.map((k) => cache.get(k)!));
1240
+ const out = concat(kids.map((k) => done.get(k)!));
1241
+ done.set(nid, out);
1207
1242
  cache.set(nid, out);
1208
1243
  }
1209
1244
 
1210
- const out = cache.get(id) ?? _ZERO;
1245
+ const out = done.get(id) ?? _ZERO;
1211
1246
  if (this.meter) this.meter.bytesRead += out.length;
1212
1247
  return out;
1213
1248
  }
@@ -1730,16 +1765,35 @@ export abstract class AbstractStore implements Store {
1730
1765
  * common-prefix / common-suffix trim: whatever remains after both trims is
1731
1766
  * the single differing span (substitution, insertion or deletion), and both
1732
1767
  * remainders must fit the budget. Scattered differences leave a wide
1733
- * middle and are rejected. */
1768
+ * middle and are rejected.
1769
+ *
1770
+ * Every read here is CAPPED (§2.8). It used to open with
1771
+ * `bytesPrefix(k, Number.MAX_SAFE_INTEGER)` — the ALL sentinel, i.e. the
1772
+ * full materialising `bytes()` read — on the deposit hot path, and only
1773
+ * then compare lengths. So a candidate the length test was about to reject
1774
+ * had already been reconstructed byte for byte. The LENGTHS decide first
1775
+ * instead, from the `contentLen` memo the interning order has already built
1776
+ * bottom-up, and the target's length is itself read under a cap: a target
1777
+ * longer than `la + W` is rejected without touching one of its bytes.
1778
+ * Same semantics — the old capped `b` read would have produced
1779
+ * `a.length + W + 1` here and failed the very same test — strictly fewer
1780
+ * byte reads. The `+ 1` on each byte cap keeps `_prefix`'s
1781
+ * "complete reconstruction" test true, so the results still cache. */
1734
1782
  private differsByOneWindow(
1735
1783
  kids: NodeId[],
1736
1784
  targetId: NodeId,
1737
1785
  W: number,
1738
1786
  ): boolean {
1739
- const a = concat(
1740
- kids.map((k) => this.bytesPrefix(k, Number.MAX_SAFE_INTEGER)),
1741
- );
1742
- const b = this.bytesPrefix(targetId, a.length + W + 1);
1787
+ const lens = kids.map((k) => this.contentLen(k));
1788
+ let la = 0;
1789
+ for (const n of lens) la += n;
1790
+ const cap = la + W + 1;
1791
+ // `contentLen` under a cap returns a clamped LOWER BOUND once the partial
1792
+ // sum reaches it, so `>= cap` is exactly "longer than la + W".
1793
+ const lb = this.contentLen(targetId, cap);
1794
+ if (lb >= cap || Math.abs(la - lb) > W) return false;
1795
+ const a = concat(kids.map((k, i) => this.bytesPrefix(k, lens[i] + 1)));
1796
+ const b = this.bytesPrefix(targetId, lb + 1);
1743
1797
  if (Math.abs(a.length - b.length) > W) return false;
1744
1798
  const n = Math.min(a.length, b.length);
1745
1799
  let i = 0;
@@ -0,0 +1,115 @@
1
+ // 96-bytes-walk-termination.test.mjs — `bytes()` must TERMINATE when its
2
+ // traversal memo cannot hold the reconstruction's working set.
3
+ //
4
+ // `bytes()` is an iterative post-order walk: it peeks the top of an explicit
5
+ // stack, pushes any child that is not yet resolved, and concatenates once every
6
+ // child is. Its termination argument needs a memo that only ever GROWS. It
7
+ // used `_bytesCache` — a byte-accounted BoundedMap that EVICTS, and whose
8
+ // `"smallest"` policy deliberately prefers the cheapest-to-rebuild entries,
9
+ // i.e. exactly the small, freshly-resolved children the parents still sitting
10
+ // on the stack are waiting for. The parent finds them unresolved again,
11
+ // re-pushes them, they are re-resolved, re-inserted and re-evicted. No
12
+ // progress. The loop never exits.
13
+ //
14
+ // It stayed hidden because it needs the cache to be SATURATED, which only a
15
+ // long run reaches. OBSERVED IN THE FIELD: a training run at 19.9M nodes with
16
+ // the 20 MB cache pinned at 19,999,962 bytes spun for 8h45m of 100% CPU on one
17
+ // node — whose entire content was 124 bytes, with 5 kids, 2 of them
18
+ // perpetually re-evicted. Sampled 45s apart through the V8 inspector, the
19
+ // walk's root id, the node's length and the cache's byte count were all
20
+ // identical; only the stack depth oscillated between 1 and 2.
21
+ //
22
+ // And the loop is SYNCHRONOUS, so nothing could observe it: the trainer's
23
+ // 15-minute stall watchdog is a timer, and a timer cannot fire while the
24
+ // microtask/JS stack is occupied. The run looked alive for 9 hours.
25
+ //
26
+ // The fix makes the walk memoize into a LOCAL map (`_bytesCache` demoted to a
27
+ // warm hint whose hit is promoted into that map immediately), so each node
28
+ // resolves at most once per call and termination is structural.
29
+ //
30
+ // This test does NOT depend on the eviction cursor's position — the field case
31
+ // reached the defect stochastically, via the cursor sweeping the map's
32
+ // recently-inserted tail. Here the subtree's children simply sum to more bytes
33
+ // than the ceiling, so no cursor position can save it: pre-fix this file hangs
34
+ // forever, and `node --test` reports it only as a timeout.
35
+ import { test } from "node:test";
36
+ import assert from "node:assert/strict";
37
+ import { SQliteStore } from "../dist/src/store-sqlite.js";
38
+
39
+ const D = 64;
40
+ const gist = () => {
41
+ const v = new Float32Array(D);
42
+ v[0] = 1;
43
+ return v;
44
+ };
45
+
46
+ test("bytes() terminates when its memo cannot hold the working set", async () => {
47
+ // A ceiling small enough to saturate in milliseconds. The mechanism is
48
+ // scale-free: this is the only thing scaled down from the field case.
49
+ const CEILING = 4096;
50
+ const store = new SQliteStore({
51
+ D,
52
+ bytesCacheMax: CEILING,
53
+ path: ":memory:",
54
+ });
55
+
56
+ // One ordinary 9,600-byte form: 300 distinct 32-byte children under a root.
57
+ const KIDS = 300, WIDTH = 32;
58
+ const kids = [];
59
+ for (let b = 0; b < KIDS; b++) {
60
+ const buf = new Uint8Array(WIDTH);
61
+ for (let i = 0; i < WIDTH; i++) buf[i] = (b * 131 + i * 17) & 0xff;
62
+ kids.push(await store.putLeaf(buf, gist()));
63
+ }
64
+ const root = await store.putBranch(kids, gist());
65
+ const expected = store.contentLen(root);
66
+ assert.equal(expected, KIDS * WIDTH);
67
+
68
+ // Churn the subtree out of the memo and leave it AT its ceiling — the steady
69
+ // state every long run reaches.
70
+ for (let k = 0; k < 4000; k++) {
71
+ const buf = new Uint8Array(48);
72
+ for (let i = 0; i < 48; i++) buf[i] = (k * 7919 + i * 251) & 0xff;
73
+ await store.putLeaf(buf, gist());
74
+ }
75
+
76
+ // The premise the guard rests on: the memo genuinely cannot hold the working
77
+ // set. If a future change grows the ceiling or shrinks the fixture, this
78
+ // assertion fails LOUDLY rather than letting the test pass vacuously.
79
+ assert.ok(
80
+ KIDS * WIDTH > CEILING,
81
+ `fixture must exceed the memo ceiling (${KIDS * WIDTH} vs ${CEILING})`,
82
+ );
83
+
84
+ // Pre-fix this call never returns. Post-fix it is sub-millisecond.
85
+ const out = store.bytes(root);
86
+ assert.equal(out.length, expected);
87
+ for (let b = 0; b < KIDS; b++) {
88
+ for (let i = 0; i < WIDTH; i++) {
89
+ assert.equal(out[b * WIDTH + i], (b * 131 + i * 17) & 0xff);
90
+ }
91
+ }
92
+ });
93
+
94
+ test("differsByOneWindow's reads are capped — no ALL-sentinel read on deposit", async () => {
95
+ // §2.8: the near-dedup byte check used to open with
96
+ // `bytesPrefix(k, Number.MAX_SAFE_INTEGER)` — the ALL sentinel, which routes
97
+ // to the full materialising `bytes()` — and only THEN compare lengths. A
98
+ // candidate the length test was about to reject had already been rebuilt byte
99
+ // for byte, and that read is what dragged the deposit path into the walk
100
+ // above. Lengths now decide first, from the `contentLen` memo.
101
+ const src = await import("node:fs").then((fs) =>
102
+ fs.readFileSync(new URL("../src/store.ts", import.meta.url), "utf8")
103
+ );
104
+ const body = src.slice(src.indexOf("private differsByOneWindow"));
105
+ const end = body.indexOf("\n }\n");
106
+ const fn = body.slice(0, end);
107
+ assert.ok(
108
+ !fn.includes("MAX_SAFE_INTEGER"),
109
+ "differsByOneWindow must not read with the ALL sentinel",
110
+ );
111
+ assert.ok(
112
+ fn.includes("this.contentLen("),
113
+ "differsByOneWindow must decide on lengths before reading bytes",
114
+ );
115
+ });