bun-shared-memory 0.1.6 → 0.1.8

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/CHANGELOG.md CHANGED
@@ -5,6 +5,58 @@ All notable changes to this project are documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [0.1.7] - 2026-11-02
9
+
10
+ ### Added
11
+
12
+ - Keep the Index Table in a single growable `SharedArrayBuffer` (capped at
13
+ 4 GiB) instead of the chunked logic, so it stays contiguous and cheap to
14
+ address. `SharedMemoryOptions.indexTableMaxByteLength` configures its cap
15
+ (default 4 GiB).
16
+ - `maxTotalBytes` now caps the combined physical size of DataTable,
17
+ StringTable, and ArrayTable only (the Index Table is excluded). The budget
18
+ is enforced by an atomic `SharedBudget` counter shared across workers that
19
+ reserves memory before each chunk growth, so the three tables cannot exceed
20
+ the limit even when multiple workers write concurrently.
21
+ - Enforce size limits regardless of the configured values: `chunkSize` is at
22
+ least 4 MiB, `maxTotalBytes`/`maxByteLength` are at least 32 MiB, and
23
+ `chunkSize` is raised as needed so no table needs more than 1024 chunks.
24
+ `SharedMemory.maxByteLength` and `SharedMemory.maxTotalBytes` are now public
25
+ read-only fields.
26
+
27
+ ### Performance
28
+
29
+ - Reclaim memory from inactive chunks after defragmentation. A
30
+ `SharedArrayBuffer` cannot be shrunk, so `GrowableBuffer` replaces each
31
+ inactive chunk with a fresh 4-byte `SharedArrayBuffer`, releasing the old
32
+ full-size buffer so its memory is freed. `defragment()` returns the new
33
+ chunk lists and the main thread broadcasts them to workers via a
34
+ `__chunkUpdate` message; workers apply them with `applyChunkUpdate()`. This
35
+ only runs during defragmentation (rare), so the hot path is unaffected.
36
+ - Optimize the budget write hot path: `GrowableBuffer` tracks
37
+ `nextChunkToActivate` (the first not-yet-active chunk) so `ensureCapacity`
38
+ only activates new chunks instead of re-scanning every already-active chunk
39
+ on each write (was O(n) per write as the dataset grew). Writes ~9% faster.
40
+
41
+ ### Changed
42
+
43
+ - Stabilize the real-world benchmark against CPU contention. The shared-memory
44
+ methodology finishes in tens of milliseconds, so it is sensitive to load
45
+ from other processes (IDE, language servers). Each worker now runs several
46
+ measured samples (default 5) and reports its fastest; each methodology is
47
+ run several times (default 3) and the fastest run is kept. Because
48
+ contention can only slow a run down, the minimum is the best estimate of
49
+ true throughput.
50
+
51
+ ### Fixed
52
+
53
+ - Resolve chunk offsets with an unsigned right shift (`offset >>> chunkShift`)
54
+ instead of a signed one. A signed 32-bit shift turns offsets at or above
55
+ 2 GiB negative, so reads/writes past 2 GiB (e.g. in a >4 GiB chunked buffer)
56
+ hit the wrong chunk or threw. The Index Table and the Data/String/Array
57
+ tables can now be addressed at any offset below the 4 GiB single-SAB cap.
58
+ Reported by [mizulu](https://github.com/mizulu).
59
+
8
60
  ## [0.1.6] - 2026-11-02
9
61
 
10
62
  ### Added
@@ -120,6 +172,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
120
172
  - O(1) in-place `push`/`pop` with capacity-based array growth.
121
173
  - Benchmarks for all data types vs native.
122
174
 
175
+ [0.1.7]: https://github.com/Kapsonfire/bun-shared-memory/compare/v0.1.6...v0.1.7
123
176
  [0.1.6]: https://github.com/Kapsonfire/bun-shared-memory/compare/v0.1.5...v0.1.6
124
177
  [0.1.5]: https://github.com/Kapsonfire/bun-shared-memory/compare/v0.1.4...v0.1.5
125
178
  [0.1.4]: https://github.com/Kapsonfire/bun-shared-memory/compare/v0.1.3...v0.1.4
package/README.md CHANGED
@@ -37,20 +37,21 @@ is benchmarked sequentially with 4 workers. See
37
37
  [`bench/real-world/README.md`](bench/real-world/README.md) for setup.
38
38
 
39
39
  Measured on Linux (Bun 1.4.4, 100,000 requests per methodology, sorted by
40
- speed; MySQL/Redis benchmarked over both TCP and Unix socket):
40
+ speed; MySQL/Redis benchmarked over both TCP and Unix socket; each methodology
41
+ is the minimum over 3 runs × 5 samples to filter CPU-contention noise):
41
42
 
42
43
  | Methodology | Transport | Requests | Elapsed (ms) | req/s | vs. shared |
43
44
  |-------------|-----------|----------|--------------|------------|------------|
44
- | `shared` | — | 100,000 | 52 | 1,925,042 | — |
45
- | `sqlite` | — | 100,000 | 187 | 535,469 | 3.6× slower |
46
- | `redis` | socket | 100,000 | 1,688 | 59,233 | 32.5× slower |
47
- | `mysql` | socket | 100,000 | 1,823 | 54,861 | 35.1× slower |
48
- | `redis` | tcp | 100,000 | 2,863 | 34,923 | 55.1× slower |
49
- | `mysql` | tcp | 100,000 | 3,281 | 30,476 | 63.2× slower |
50
-
51
- Shared memory is the fastest by a wide margin: ~3.6× faster than a live SQLite
52
- read and ~33–63× faster than live MySQL/Redis reads. Unix sockets are
53
- significantly faster than TCP for MySQL/Redis (~1.7×), but both remain far
45
+ | `shared` | — | 100,000 | 21 | 4,876,492 | — |
46
+ | `sqlite` | — | 100,000 | 188 | 531,116 | 9.2× slower |
47
+ | `redis` | socket | 100,000 | 1,262 | 79,229 | 61.5× slower |
48
+ | `mysql` | socket | 100,000 | 1,605 | 62,322 | 78.2× slower |
49
+ | `redis` | tcp | 100,000 | 2,817 | 35,497 | 137.4× slower |
50
+ | `mysql` | tcp | 100,000 | 2,976 | 33,602 | 145.1× slower |
51
+
52
+ Shared memory is the fastest by a wide margin: ~9.2× faster than a live SQLite
53
+ read and ~61–145× faster than live MySQL/Redis reads. Unix sockets are
54
+ significantly faster than TCP for MySQL/Redis (~1.8×), but both remain far
54
55
  slower than shared memory.
55
56
 
56
57
  ## ✨ Features
@@ -305,22 +306,24 @@ A complete runnable example is in [`examples/worker/`](examples/worker/).
305
306
 
306
307
  ## Memory budget
307
308
 
308
- Shared memory is split across several growable `SharedArrayBuffer`s (the
309
- index, data, string, and array tables; each key's version counter lives in its
310
- index entry). A single `SharedArrayBuffer` can be at most **4 GiB**. By default
311
- each buffer grows independently up to its own `maxByteLength`, so the _total_
312
- memory can be several times that.
309
+ The Index Table (including each key's version counter) uses one growable
310
+ `SharedArrayBuffer`, capped at **4 GiB** by default (configurable via
311
+ `indexTableMaxByteLength`). The DataTable, StringTable, and ArrayTable use
312
+ chunks and can individually exceed 4 GiB. By default each table grows up to
313
+ its own `maxByteLength`, so the _total_ memory can be several times that.
313
314
 
314
315
  Configure the buffers when calling `init()` — pass `initialSize` (each
315
- buffer's starting size, avoiding a grow on first use) and `maxByteLength`
316
- (each buffer's cap). To cap the **total** memory across all buffers, pass
317
- `maxTotalBytes`:
316
+ table's starting size, avoiding a grow on first use) and `maxByteLength`
317
+ (each table's cap). To cap the combined physical memory of DataTable,
318
+ StringTable, and ArrayTable, pass `maxTotalBytes`. The Index Table is excluded
319
+ from this budget. Its growth and chunk growth in workers reserve memory from
320
+ the same atomic budget before allocating, so the three tables cannot exceed it.
318
321
 
319
322
  ```ts
320
323
  import { init } from "bun-shared-memory";
321
324
 
322
- // Each buffer starts at 64 KiB and grows up to 4 GiB; the sum of all buffers'
323
- // physical sizes is additionally capped at 4 GiB (maxTotalBytes).
325
+ // The Index Table grows up to 4 GiB independently. DataTable, StringTable, and
326
+ // ArrayTable together are capped at 4 GiB.
324
327
  await init({
325
328
  initialSize: 64 * 1024,
326
329
  maxByteLength: 4 * 1024 * 1024 * 1024,
@@ -328,14 +331,24 @@ await init({
328
331
  });
329
332
  ```
330
333
 
331
- `maxTotalBytes` caps the **sum** of all buffers' physical sizes, so the total
332
- stays within the budget. If a write would exceed the budget, a `RangeError` is
333
- thrown. Without `maxTotalBytes`, each buffer simply grows up to its own
334
- `maxByteLength`.
334
+ `maxTotalBytes` caps the **sum** of the physical sizes of DataTable,
335
+ StringTable, and ArrayTable, including across workers. If a write would exceed
336
+ the budget, a `RangeError` is thrown before the chunk grows. Without
337
+ `maxTotalBytes`, each table simply grows up to its own `maxByteLength`.
335
338
 
336
- The budget is shared dynamically — buffers grow as needed, but only while the
337
- sum of all buffers stays within `maxTotalBytes`. If a write would exceed the
338
- budget, a `RangeError` is thrown.
339
+ ### Size limits
340
+
341
+ The following limits are enforced regardless of the configured values:
342
+
343
+ - **`chunkSize` is at least 4 MiB.** A smaller value is raised to 4 MiB.
344
+ - **`maxTotalBytes` / `maxByteLength` are at least 32 MiB.** A smaller value
345
+ is raised to 32 MiB.
346
+ - **No table uses more than 1024 chunks.** `chunkSize` is raised as needed so
347
+ that `ceil(maxByteLength / chunkSize) ≤ 1024` (e.g. an 8 GiB table with a
348
+ 4 MiB chunk would need 2048 chunks, so the chunk size is raised to 8 MiB).
349
+
350
+ These keep the chunk count bounded (so the pre-allocated chunk list stays
351
+ small) and avoid pathological tiny chunks.
339
352
 
340
353
  ## Array capacity
341
354
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bun-shared-memory",
3
- "version": "0.1.6",
3
+ "version": "0.1.8",
4
4
  "description": "Share data between Bun workers/threads using SharedArrayBuffer (zero-copy, 4-buffer architecture, Atomics-based synchronization).",
5
5
  "type": "module",
6
6
  "sideEffects": false,
@@ -1,52 +1,77 @@
1
1
  /**
2
2
  * Shared memory budget shared across multiple GrowableBuffers.
3
3
  *
4
- * Tracks the sum of the physical sizes of all participating buffers so a
5
- * caller can cap the *total* memory used by a set of buffers, rather than
6
- * capping each buffer independently. Growth is only allowed if the new total
7
- * stays within the budget.
4
+ * Tracks the combined physical size of DataTable, StringTable, and ArrayTable.
5
+ * The counter is shared across workers so each growth reserves against the
6
+ * same limit.
8
7
  */
9
8
  export class SharedBudget {
10
- private total = 0;
9
+ private readonly used: BigInt64Array;
11
10
 
12
- constructor(private readonly max: number) {}
11
+ constructor(
12
+ private readonly max: number,
13
+ buffer = new SharedArrayBuffer(BigInt64Array.BYTES_PER_ELEMENT),
14
+ ) {
15
+ this.used = new BigInt64Array(buffer);
16
+ }
17
+
18
+ get buffer(): SharedArrayBuffer {
19
+ return this.used.buffer as SharedArrayBuffer;
20
+ }
13
21
 
14
22
  /** Record a buffer's initial physical size. */
15
23
  add(size: number): void {
16
- this.total += size;
17
- if (this.total > this.max) {
24
+ if (!this.tryReserve(size)) {
25
+ const total = Atomics.load(this.used, 0) + BigInt(size);
18
26
  throw new RangeError(
19
- `Shared memory budget exceeded: initial buffers total ${this.total} bytes but the budget is ${this.max}`,
27
+ `Shared memory budget exceeded: initial buffers total ${total} bytes but the budget is ${this.max}`,
20
28
  );
21
29
  }
22
30
  }
23
31
 
24
32
  /**
25
- * Attempt to grow a buffer from `oldSize` to `newSize`. Returns true and
26
- * updates the total if the new total stays within the budget, false
27
- * otherwise (the caller must not grow).
33
+ * Atomically reserve physical memory shared by all tables and workers.
28
34
  */
29
- tryGrow(oldSize: number, newSize: number): boolean {
30
- const newTotal = this.total - oldSize + newSize;
31
- if (newTotal > this.max) return false;
32
- this.total = newTotal;
33
- return true;
35
+ tryReserve(size: number): boolean {
36
+ if (size <= 0) return true;
37
+ const increment = BigInt(size);
38
+ for (;;) {
39
+ const current = Atomics.load(this.used, 0);
40
+ if (current + increment > BigInt(this.max)) return false;
41
+ if (
42
+ Atomics.compareExchange(this.used, 0, current, current + increment) ===
43
+ current
44
+ ) {
45
+ return true;
46
+ }
47
+ }
48
+ }
49
+
50
+ /**
51
+ * Release memory reserved for a growth that failed or a reclaimed chunk.
52
+ */
53
+ release(size: number): void {
54
+ if (size <= 0) return;
55
+ const decrement = BigInt(size);
56
+ for (;;) {
57
+ const current = Atomics.load(this.used, 0);
58
+ const next = current > decrement ? current - decrement : 0n;
59
+ if (Atomics.compareExchange(this.used, 0, current, next) === current) {
60
+ return;
61
+ }
62
+ }
34
63
  }
35
64
  }
36
65
 
37
66
  /**
38
- * Growable shared memory backed by one or more SharedArrayBuffers (chunks).
67
+ * Growable shared memory backed by one or more SharedArrayBuffers. Chunked
68
+ * tables split storage across SABs; the Index Table uses one growable SAB.
69
+ * Offsets are logical and continuous across chunks.
39
70
  *
40
- * A single SharedArrayBuffer can be at most 4 GiB, so a buffer that needs to
41
- * grow beyond that is split into multiple chunks. Each chunk is its own
42
- * SharedArrayBuffer (<= `chunkMax` bytes). Offsets are logical and continuous
43
- * across chunks; access methods resolve an offset into (chunk, chunkOffset).
44
- *
45
- * All chunks are pre-allocated at construction with `maxByteLength = chunkSize`
46
- * but only 4 bytes of actual storage. A chunk is "activated" (grown to its
47
- * full `chunkSize`) on first access. Because `SharedArrayBuffer.grow()` is
48
- * visible across all workers sharing the buffer, activation needs no
49
- * postMessage broadcast.
71
+ * In chunked mode, all chunks are pre-allocated with `maxByteLength =
72
+ * chunkSize` but only 4 bytes of actual storage. A chunk is activated (grown
73
+ * to its full size) on first access. Because `SharedArrayBuffer.grow()` is
74
+ * visible across workers, activation needs no postMessage broadcast.
50
75
  */
51
76
  interface Chunk {
52
77
  buffer: SharedArrayBuffer;
@@ -56,33 +81,97 @@ interface Chunk {
56
81
  }
57
82
 
58
83
  /** Round `n` up to the next power of two (chunk sizes must be powers of two). */
59
- function nextPowerOfTwo(n: number): number {
84
+ export function nextPowerOfTwo(n: number): number {
60
85
  if (n <= 0) return 1;
61
86
  let p = 1;
62
87
  while (p < n) p <<= 1;
63
88
  return p;
64
89
  }
65
90
 
91
+ export function largestPowerOfTwo(n: number): number {
92
+ if (n <= 1) return 1;
93
+ return 2 ** Math.floor(Math.log2(n));
94
+ }
95
+
66
96
  export class GrowableBuffer {
67
97
  private chunks: Chunk[] = [];
68
98
  private readonly maxByteLength: number;
99
+ private readonly singleBuffer: boolean;
69
100
  /** Fixed size of every chunk (the minimum growth step). */
70
101
  readonly chunkSize: number;
71
102
  /** log2(chunkSize), used for O(1) offset resolution via bit shifts. */
72
103
  private readonly chunkShift: number;
73
104
  private readonly budget?: SharedBudget;
74
- private used = 0;
105
+ /**
106
+ * Shared append cursor (used length). Stored in a SharedArrayBuffer so all
107
+ * workers that reconstruct this buffer from `fromBuffers()` see the same
108
+ * used length and append at the correct offset instead of overwriting
109
+ * existing data at offset 0.
110
+ */
111
+ private readonly cursor: BigInt64Array;
112
+ /** Smallest chunk index that is not yet active (see `activate`). */
113
+ private nextChunkToActivate = 0;
75
114
 
76
115
  constructor(
77
116
  initialSizeOrBuffer: number | SharedArrayBuffer | SharedArrayBuffer[],
78
117
  maxByteLength: number,
79
118
  budget?: SharedBudget,
80
119
  chunkSize = 32 * 1024 * 1024, // 32 MiB per chunk
120
+ singleBuffer = false,
121
+ chargeInitial = true,
122
+ cursorBuffer?: SharedArrayBuffer,
81
123
  ) {
82
- this.maxByteLength = maxByteLength;
124
+ this.singleBuffer = singleBuffer;
83
125
  this.budget = budget;
126
+ this.cursor = new BigInt64Array(
127
+ cursorBuffer ?? new SharedArrayBuffer(BigInt64Array.BYTES_PER_ELEMENT),
128
+ );
129
+ if (singleBuffer && typeof initialSizeOrBuffer !== "number") {
130
+ const buffer = Array.isArray(initialSizeOrBuffer)
131
+ ? initialSizeOrBuffer[0]
132
+ : initialSizeOrBuffer;
133
+ if (
134
+ buffer === undefined ||
135
+ (Array.isArray(initialSizeOrBuffer) && initialSizeOrBuffer.length !== 1)
136
+ ) {
137
+ throw new RangeError(
138
+ "A single-buffer GrowableBuffer requires exactly one SharedArrayBuffer",
139
+ );
140
+ }
141
+ this.maxByteLength = Math.min(
142
+ buffer.maxByteLength ?? buffer.byteLength,
143
+ maxByteLength,
144
+ );
145
+ this.chunkSize = this.maxByteLength;
146
+ this.chunkShift = 0;
147
+ this.chunks.push({
148
+ buffer,
149
+ view: new DataView(buffer),
150
+ active: buffer.byteLength > 4,
151
+ });
152
+ if (chargeInitial) this.budget?.add(buffer.byteLength);
153
+ return;
154
+ }
155
+
156
+ this.maxByteLength = maxByteLength;
84
157
  if (typeof initialSizeOrBuffer === "number") {
85
- this.chunkSize = Math.min(nextPowerOfTwo(chunkSize), maxByteLength);
158
+ if (singleBuffer) {
159
+ const buffer = new SharedArrayBuffer(4, {
160
+ maxByteLength,
161
+ });
162
+ this.chunkSize = maxByteLength;
163
+ this.chunkShift = 0;
164
+ this.chunks.push({
165
+ buffer,
166
+ view: new DataView(buffer),
167
+ active: false,
168
+ });
169
+ return;
170
+ }
171
+ this.chunkSize = Math.min(
172
+ nextPowerOfTwo(chunkSize),
173
+ largestPowerOfTwo(maxByteLength),
174
+ );
86
175
  this.chunkShift = Math.log2(this.chunkSize);
87
176
  // Pre-allocate all chunks (4 bytes each, growable to chunkSize).
88
177
  const chunkCount = Math.max(1, Math.ceil(maxByteLength / this.chunkSize));
@@ -95,6 +184,7 @@ export class GrowableBuffer {
95
184
  view: new DataView(buffer),
96
185
  active: false,
97
186
  });
187
+ if (chargeInitial) this.budget?.add(buffer.byteLength);
98
188
  }
99
189
  } else if (Array.isArray(initialSizeOrBuffer)) {
100
190
  // Reconstruct from a list of chunks. Use the buffers directly (they are
@@ -110,7 +200,7 @@ export class GrowableBuffer {
110
200
  view: new DataView(sab),
111
201
  active: sab.byteLength === this.chunkSize,
112
202
  });
113
- this.budget?.add(sab.byteLength);
203
+ if (chargeInitial) this.budget?.add(sab.byteLength);
114
204
  }
115
205
  } else {
116
206
  // Reconstruct from a single existing buffer. Use it directly so writes
@@ -124,7 +214,7 @@ export class GrowableBuffer {
124
214
  view: new DataView(initialSizeOrBuffer),
125
215
  active: initialSizeOrBuffer.byteLength === this.chunkSize,
126
216
  });
127
- this.budget?.add(initialSizeOrBuffer.byteLength);
217
+ if (chargeInitial) this.budget?.add(initialSizeOrBuffer.byteLength);
128
218
  }
129
219
  }
130
220
 
@@ -136,20 +226,70 @@ export class GrowableBuffer {
136
226
  private activate(index: number): void {
137
227
  const chunk = this.chunks[index];
138
228
  if (chunk === undefined || chunk.active) return;
139
- chunk.buffer.grow(this.chunkSize);
229
+ if (this.singleBuffer) {
230
+ return;
231
+ }
232
+ const oldSize = chunk.buffer.byteLength;
233
+ if (oldSize >= this.chunkSize) {
234
+ chunk.view = new DataView(chunk.buffer);
235
+ chunk.active = true;
236
+ this.advanceNextChunkToActivate(index);
237
+ return;
238
+ }
239
+ const growth = this.chunkSize - oldSize;
240
+ if (this.budget && !this.budget.tryReserve(growth)) {
241
+ throw new RangeError(
242
+ `Shared memory budget exceeded: need ${growth} additional bytes`,
243
+ );
244
+ }
245
+ try {
246
+ chunk.buffer.grow(this.chunkSize);
247
+ } catch (error) {
248
+ if (chunk.buffer.byteLength >= this.chunkSize) {
249
+ this.budget?.release(growth);
250
+ chunk.view = new DataView(chunk.buffer);
251
+ chunk.active = true;
252
+ this.advanceNextChunkToActivate(index);
253
+ return;
254
+ }
255
+ this.budget?.release(growth);
256
+ throw error;
257
+ }
140
258
  chunk.view = new DataView(chunk.buffer);
141
259
  chunk.active = true;
142
- this.budget?.add(this.chunkSize);
260
+ this.advanceNextChunkToActivate(index);
261
+ }
262
+
263
+ /**
264
+ * Advance `nextChunkToActivate` past `index` while the following chunks are
265
+ * already active, so `ensureCapacity` never re-scans active chunks.
266
+ */
267
+ private advanceNextChunkToActivate(index: number): void {
268
+ if (index < this.nextChunkToActivate) return;
269
+ this.nextChunkToActivate = index + 1;
270
+ while (
271
+ this.nextChunkToActivate < this.chunks.length &&
272
+ this.chunks[this.nextChunkToActivate]!.active
273
+ ) {
274
+ this.nextChunkToActivate++;
275
+ }
143
276
  }
144
277
 
145
278
  /** Number of bytes written so far (used length, not buffer capacity). */
146
279
  get byteLength(): number {
147
- return this.used;
280
+ return Number(Atomics.load(this.cursor, 0));
281
+ }
282
+
283
+ /** The shared cursor buffer (for export to workers). */
284
+ get cursorBuffer(): SharedArrayBuffer {
285
+ return this.cursor.buffer as SharedArrayBuffer;
148
286
  }
149
287
 
150
288
  /** Total logical capacity across all chunks (active or not). */
151
289
  get capacity(): number {
152
- return this.chunks.length * this.chunkSize;
290
+ return this.singleBuffer
291
+ ? this.maxByteLength
292
+ : this.chunks.length * this.chunkSize;
153
293
  }
154
294
 
155
295
  /** The list of SharedArrayBuffers backing this buffer (for export). */
@@ -157,18 +297,43 @@ export class GrowableBuffer {
157
297
  return this.chunks.map((c) => c.buffer);
158
298
  }
159
299
 
160
- /** Number of chunks that have been activated (grown to full size). */
300
+ /** Number of chunks that have been grown from their initial 4-byte size. */
161
301
  get activeChunkCount(): number {
162
302
  return this.chunks.reduce((n, c) => n + (c.active ? 1 : 0), 0);
163
303
  }
164
304
 
305
+ /**
306
+ * Replace the chunk list with `newBuffers` (received from the main thread
307
+ * after a defragmentation broadcast). Each buffer is either a full-size
308
+ * active chunk or a fresh 4-byte inactive chunk. Used by workers to stay in
309
+ * sync with the main thread's chunk replacement.
310
+ */
311
+ applyChunks(newBuffers: SharedArrayBuffer[]): void {
312
+ if (this.singleBuffer && newBuffers.length !== 1) {
313
+ throw new RangeError(
314
+ "A single-buffer GrowableBuffer requires exactly one SharedArrayBuffer",
315
+ );
316
+ }
317
+ this.chunks = newBuffers.map((sab) => ({
318
+ buffer: sab,
319
+ view: new DataView(sab),
320
+ active: this.singleBuffer
321
+ ? sab.byteLength > 4
322
+ : sab.byteLength === this.chunkSize,
323
+ }));
324
+ this.nextChunkToActivate = this.firstInactiveChunk();
325
+ }
326
+
165
327
  /**
166
328
  * Resolve a logical offset into (chunk, offset within that chunk). All
167
329
  * chunks are the same size, so this is O(1). Activates the chunk if it is
168
330
  * not yet active.
169
331
  */
170
332
  private resolve(offset: number): { chunk: Chunk; off: number } {
171
- const index = offset >> this.chunkShift;
333
+ if (this.singleBuffer) {
334
+ return { chunk: this.chunks[0]!, off: offset };
335
+ }
336
+ const index = offset >>> this.chunkShift;
172
337
  const chunk = this.chunks[index];
173
338
  if (chunk === undefined) {
174
339
  throw new RangeError(`Offset ${offset} is beyond the buffer capacity`);
@@ -184,31 +349,99 @@ export class GrowableBuffer {
184
349
  `Shared buffer capacity exceeded: need ${needed} bytes but max is ${this.maxByteLength}`,
185
350
  );
186
351
  }
352
+ if (this.singleBuffer) {
353
+ const chunk = this.chunks[0]!;
354
+ const oldSize = chunk.buffer.byteLength;
355
+ if (needed <= oldSize) return;
356
+ const target = Math.min(
357
+ this.maxByteLength,
358
+ Math.max(needed, oldSize * 2),
359
+ );
360
+ let growth = target - oldSize;
361
+ if (this.budget && !this.budget.tryReserve(growth)) {
362
+ if (target === needed || !this.budget.tryReserve(needed - oldSize)) {
363
+ throw new RangeError(
364
+ `Shared memory budget exceeded: need ${needed - oldSize} additional bytes`,
365
+ );
366
+ }
367
+ growth = needed - oldSize;
368
+ }
369
+ try {
370
+ chunk.buffer.grow(oldSize + growth);
371
+ } catch (error) {
372
+ this.budget?.release(growth);
373
+ throw error;
374
+ }
375
+ chunk.view = new DataView(chunk.buffer);
376
+ chunk.active = true;
377
+ return;
378
+ }
187
379
  // All chunks are pre-allocated; just activate the ones needed to cover
188
380
  // `needed`. grow() is visible across workers, so no broadcast is required.
381
+ // Start from `nextChunkToActivate` so already-active chunks are not
382
+ // re-scanned on every write (keeps the write hot path O(1) amortized).
189
383
  const neededChunks = Math.ceil(needed / this.chunkSize);
190
- for (let i = 0; i < neededChunks; i++) {
384
+ for (let i = this.nextChunkToActivate; i < neededChunks; i++) {
191
385
  this.activate(i);
192
386
  }
193
387
  }
194
388
 
195
389
  /** Mark `n` bytes as used, after writing them at the current offset. */
196
390
  advance(n: number): void {
197
- this.used += n;
391
+ Atomics.add(this.cursor, 0, BigInt(n));
198
392
  }
199
393
 
200
394
  /**
201
395
  * Reset the used length to `n` bytes. Used by defragmentation after
202
396
  * compacting live data to the front of the buffer. Chunks beyond the
203
- * compacted region are marked inactive (their storage is released by the
204
- * GC, which replaces them with fresh chunks).
397
+ * compacted region are marked inactive; their storage is reclaimed by
398
+ * `releaseInactiveChunks()`.
205
399
  */
206
400
  reset(n: number): void {
207
- this.used = n;
401
+ Atomics.store(this.cursor, 0, BigInt(n));
402
+ if (this.singleBuffer) return;
208
403
  const neededChunks = Math.ceil(n / this.chunkSize);
209
- for (let i = neededChunks; i < this.chunks.length; i++) {
210
- this.chunks[i]!.active = false;
404
+ for (let i = 0; i < this.chunks.length; i++) {
405
+ const chunk = this.chunks[i]!;
406
+ chunk.active =
407
+ i < neededChunks && chunk.buffer.byteLength >= this.chunkSize;
408
+ }
409
+ this.nextChunkToActivate = this.firstInactiveChunk();
410
+ }
411
+
412
+ /**
413
+ * Replace every inactive chunk with a fresh 4-byte SharedArrayBuffer
414
+ * (growable to `chunkSize`), releasing the old full-size buffer so its
415
+ * memory can be reclaimed. Returns the new chunk list; the caller must
416
+ * broadcast it to workers so they see the replacement.
417
+ *
418
+ * Because a SharedArrayBuffer cannot be shrunk, reclaiming memory requires
419
+ * allocating a new buffer. This is only called after defragmentation (rare),
420
+ * so the resulting broadcast is acceptable.
421
+ */
422
+ releaseInactiveChunks(): SharedArrayBuffer[] {
423
+ if (this.singleBuffer) return this.chunks.map((c) => c.buffer);
424
+ for (let i = 0; i < this.chunks.length; i++) {
425
+ const chunk = this.chunks[i]!;
426
+ if (chunk.active) continue;
427
+ // Release the old full-size buffer from the budget and replace it with
428
+ // a fresh 4-byte chunk.
429
+ this.budget?.release(Math.max(0, chunk.buffer.byteLength - 4));
430
+ const buffer = new SharedArrayBuffer(4, {
431
+ maxByteLength: this.chunkSize,
432
+ });
433
+ this.chunks[i] = { buffer, view: new DataView(buffer), active: false };
211
434
  }
435
+ this.nextChunkToActivate = this.firstInactiveChunk();
436
+ return this.chunks.map((c) => c.buffer);
437
+ }
438
+
439
+ /** Index of the first chunk that is not active, or `chunks.length`. */
440
+ private firstInactiveChunk(): number {
441
+ for (let i = 0; i < this.chunks.length; i++) {
442
+ if (!this.chunks[i]!.active) return i;
443
+ }
444
+ return this.chunks.length;
212
445
  }
213
446
 
214
447
  // -------------------------------------------------------------------------
@@ -216,7 +449,8 @@ export class GrowableBuffer {
216
449
  // -------------------------------------------------------------------------
217
450
 
218
451
  getInt32(offset: number): number {
219
- const index = offset >> this.chunkShift;
452
+ if (this.singleBuffer) return this.chunks[0]!.view.getInt32(offset, true);
453
+ const index = offset >>> this.chunkShift;
220
454
  this.activate(index);
221
455
  return this.chunks[index]!.view.getInt32(
222
456
  offset & (this.chunkSize - 1),
@@ -225,7 +459,11 @@ export class GrowableBuffer {
225
459
  }
226
460
 
227
461
  setInt32(offset: number, value: number): void {
228
- const index = offset >> this.chunkShift;
462
+ if (this.singleBuffer) {
463
+ this.chunks[0]!.view.setInt32(offset, value, true);
464
+ return;
465
+ }
466
+ const index = offset >>> this.chunkShift;
229
467
  this.activate(index);
230
468
  this.chunks[index]!.view.setInt32(
231
469
  offset & (this.chunkSize - 1),
@@ -235,13 +473,18 @@ export class GrowableBuffer {
235
473
  }
236
474
 
237
475
  getUint8(offset: number): number {
238
- const index = offset >> this.chunkShift;
476
+ if (this.singleBuffer) return this.chunks[0]!.view.getUint8(offset);
477
+ const index = offset >>> this.chunkShift;
239
478
  this.activate(index);
240
479
  return this.chunks[index]!.view.getUint8(offset & (this.chunkSize - 1));
241
480
  }
242
481
 
243
482
  setUint8(offset: number, value: number): void {
244
- const index = offset >> this.chunkShift;
483
+ if (this.singleBuffer) {
484
+ this.chunks[0]!.view.setUint8(offset, value);
485
+ return;
486
+ }
487
+ const index = offset >>> this.chunkShift;
245
488
  this.activate(index);
246
489
  this.chunks[index]!.view.setUint8(offset & (this.chunkSize - 1), value);
247
490
  }
package/src/memory.ts CHANGED
@@ -1,4 +1,8 @@
1
- import { GrowableBuffer, SharedBudget } from "./growable-buffer.ts";
1
+ import {
2
+ GrowableBuffer,
3
+ SharedBudget,
4
+ nextPowerOfTwo,
5
+ } from "./growable-buffer.ts";
2
6
  import { Mutex } from "./mutex.ts";
3
7
 
4
8
  /**
@@ -30,10 +34,11 @@ import { Mutex } from "./mutex.ts";
30
34
  * [12..15] reserved
31
35
  * then propertyCount * (nameRef(8 bytes) + valueSlot(16 bytes))
32
36
  *
33
- * Index Table entry (12 bytes):
37
+ * Index Table entry (16 bytes):
34
38
  * [0..3] key string offset
35
39
  * [4..7] key string length
36
40
  * [8..11] data offset (into DataTable)
41
+ * [12..15] key version
37
42
  */
38
43
 
39
44
  export const TYPE_NUMBER = 0;
@@ -51,11 +56,19 @@ const INDEX_ENTRY_SIZE = 16;
51
56
  const INDEX_HEADER = 4;
52
57
  /** Byte offset of the version field within an index entry. */
53
58
  const INDEX_VERSION_OFFSET = 12;
59
+ const MAX_INDEX_TABLE_BYTES = 4 * 1024 * 1024 * 1024;
54
60
  /** Size of the objectId header at the start of each array block. */
55
61
  const ARRAY_HEADER = 4;
56
62
 
57
63
  const DEFAULT_INITIAL = 1024;
58
64
  const DEFAULT_MAX = 64 * 1024 * 1024; // 64 MiB
65
+ const DEFAULT_CHUNK_SIZE = 32 * 1024 * 1024; // 32 MiB per chunk
66
+ /** Minimum size of a single chunk (a chunk is the minimum growth step). */
67
+ const MIN_CHUNK_SIZE = 4 * 1024 * 1024; // 4 MiB
68
+ /** Minimum combined memory budget (maxTotalBytes / maxByteLength). */
69
+ const MIN_MAX_MEMORY = 32 * 1024 * 1024; // 32 MiB
70
+ /** Maximum number of chunks per table (chunkSize must be large enough). */
71
+ const MAX_CHUNKS_PER_TABLE = 1024;
59
72
  const DEFAULT_INTERN_CAPACITY = 256;
60
73
  const DEFAULT_INTERN_MAX = 1 << 16; // 65536 entries
61
74
 
@@ -172,10 +185,20 @@ export function decodeSlot(slot: ValueSlot): unknown {
172
185
 
173
186
  export interface SharedMemoryOptions {
174
187
  initialSize?: number;
188
+ /** Per-buffer maximum byte length (clamped to at least 32 MiB). */
175
189
  maxByteLength?: number;
176
- /** Total memory budget shared across all buffers (see below). */
190
+ /**
191
+ * Total physical memory budget shared by DataTable, StringTable, and
192
+ * ArrayTable (clamped to at least 32 MiB).
193
+ */
177
194
  maxTotalBytes?: number;
178
- /** Size of each growable chunk (the minimum growth step). Default 32 MiB. */
195
+ /** Internal worker reconstruction option; normally inferred from buffers. */
196
+ indexTableMaxByteLength?: number;
197
+ /**
198
+ * Size of each growable chunk (the minimum growth step). Default 32 MiB,
199
+ * clamped to at least 4 MiB and raised as needed so no table needs more
200
+ * than 1024 chunks.
201
+ */
179
202
  chunkSize?: number;
180
203
  lock?: Mutex;
181
204
  internCapacity?: number;
@@ -187,6 +210,13 @@ export interface SharedMemoryOptions {
187
210
  arrayTable?: SharedArrayBuffer | SharedArrayBuffer[];
188
211
  layoutVersion?: SharedArrayBuffer;
189
212
  refCount?: SharedArrayBuffer;
213
+ budget?: SharedArrayBuffer;
214
+ cursors?: {
215
+ indexTable?: SharedArrayBuffer;
216
+ dataTable?: SharedArrayBuffer;
217
+ stringTable?: SharedArrayBuffer;
218
+ arrayTable?: SharedArrayBuffer;
219
+ };
190
220
  };
191
221
  }
192
222
 
@@ -197,11 +227,30 @@ function makeBuffer(
197
227
  maxByteLength: number,
198
228
  budget?: SharedBudget,
199
229
  chunkSize?: number,
230
+ singleBuffer = false,
231
+ chargeInitial = true,
232
+ cursorBuffer?: SharedArrayBuffer,
200
233
  ): GrowableBuffer {
201
234
  if (buf === undefined) {
202
- return new GrowableBuffer(initial, maxByteLength, budget, chunkSize);
235
+ return new GrowableBuffer(
236
+ initial,
237
+ maxByteLength,
238
+ budget,
239
+ chunkSize,
240
+ singleBuffer,
241
+ chargeInitial,
242
+ cursorBuffer,
243
+ );
203
244
  }
204
- return new GrowableBuffer(buf, maxByteLength, budget, chunkSize);
245
+ return new GrowableBuffer(
246
+ buf,
247
+ maxByteLength,
248
+ budget,
249
+ chunkSize,
250
+ singleBuffer,
251
+ chargeInitial,
252
+ cursorBuffer,
253
+ );
205
254
  }
206
255
 
207
256
  /**
@@ -213,6 +262,12 @@ export class SharedMemory {
213
262
  readonly dataTable: GrowableBuffer;
214
263
  readonly stringTable: GrowableBuffer;
215
264
  readonly arrayTable: GrowableBuffer;
265
+ /** Per-buffer maximum byte length (clamped to at least 32 MiB). */
266
+ readonly maxByteLength: number;
267
+ private readonly indexTableMaxByteLength: number;
268
+ /** Combined memory budget for Data/String/Array tables, or null if unset. */
269
+ readonly maxTotalBytes: number | null;
270
+ private readonly budgetBuffer: SharedArrayBuffer | null;
216
271
  /**
217
272
  * Cached Int32Array view over the Index Table, used to read/increment each
218
273
  * key's version cell (stored as the 4th field of each index entry). Rebuilt
@@ -262,25 +317,59 @@ export class SharedMemory {
262
317
 
263
318
  constructor(options?: SharedMemoryOptions) {
264
319
  const initial = options?.initialSize ?? DEFAULT_INITIAL;
265
- // `maxTotalBytes` is a *total* budget shared across all buffers: each
266
- // buffer's own maxByteLength is capped at maxTotalBytes, and the sum of
267
- // all buffers' physical sizes is additionally capped at maxTotalBytes.
320
+ // `maxTotalBytes` caps the combined physical size of DataTable,
321
+ // StringTable, and ArrayTable. IndexTable has its independent 4 GiB cap.
268
322
  // Without maxTotalBytes, each buffer grows up to its own maxByteLength.
269
- // A single SharedArrayBuffer is limited to 4 GiB, but the chunked
270
- // GrowableBuffer splits a buffer across multiple chunks, so a buffer can
271
- // exceed 4 GiB.
323
+ // The Index Table remains a single growable SharedArrayBuffer and is
324
+ // capped at 4 GiB. The other tables use chunks and can exceed that limit.
272
325
  const maxTotal = options?.maxTotalBytes;
273
- const perBufferMax = maxTotal ?? options?.maxByteLength ?? DEFAULT_MAX;
274
- const budget =
275
- maxTotal !== undefined ? new SharedBudget(maxTotal) : undefined;
276
- const chunkSize = options?.chunkSize;
326
+ if (
327
+ maxTotal !== undefined &&
328
+ (!Number.isSafeInteger(maxTotal) || maxTotal < 0)
329
+ ) {
330
+ throw new RangeError("maxTotalBytes must be a non-negative safe integer");
331
+ }
332
+ // Enforce the minimum memory budget: maxTotalBytes / maxByteLength are
333
+ // never below 32 MiB.
334
+ const perBufferMax = Math.max(
335
+ maxTotal ?? options?.maxByteLength ?? DEFAULT_MAX,
336
+ MIN_MAX_MEMORY,
337
+ );
338
+ const indexTableMax = Math.min(
339
+ options?.indexTableMaxByteLength ?? options?.maxByteLength ?? DEFAULT_MAX,
340
+ MAX_INDEX_TABLE_BYTES,
341
+ );
342
+ // Enforce the chunk-size limits: at least 4 MiB, and large enough that no
343
+ // table needs more than MAX_CHUNKS_PER_TABLE chunks.
344
+ const userChunkSize = options?.chunkSize ?? DEFAULT_CHUNK_SIZE;
345
+ const chunkSize = Math.max(
346
+ userChunkSize,
347
+ MIN_CHUNK_SIZE,
348
+ nextPowerOfTwo(perBufferMax / MAX_CHUNKS_PER_TABLE),
349
+ );
277
350
  const buffers = options?.buffers;
351
+ // The budget is the clamped maxTotalBytes (never below MIN_MAX_MEMORY), so
352
+ // it stays consistent with perBufferMax.
353
+ const budgetMax = maxTotal !== undefined ? perBufferMax : undefined;
354
+ const budgetBuffer = buffers?.budget;
355
+ const budget =
356
+ budgetMax !== undefined
357
+ ? new SharedBudget(budgetMax, budgetBuffer)
358
+ : undefined;
359
+ const chargeInitial = budgetBuffer === undefined;
360
+ this.maxByteLength = perBufferMax;
361
+ this.indexTableMaxByteLength = indexTableMax;
362
+ this.maxTotalBytes = budgetMax ?? null;
363
+ this.budgetBuffer = budget?.buffer ?? null;
278
364
  this.indexTable = makeBuffer(
279
365
  buffers?.indexTable,
280
366
  initial,
281
- perBufferMax,
282
- budget,
367
+ indexTableMax,
368
+ undefined,
283
369
  chunkSize,
370
+ true,
371
+ true,
372
+ buffers?.cursors?.indexTable,
284
373
  );
285
374
  this.dataTable = makeBuffer(
286
375
  buffers?.dataTable,
@@ -288,6 +377,9 @@ export class SharedMemory {
288
377
  perBufferMax,
289
378
  budget,
290
379
  chunkSize,
380
+ false,
381
+ chargeInitial,
382
+ buffers?.cursors?.dataTable,
291
383
  );
292
384
  this.stringTable = makeBuffer(
293
385
  buffers?.stringTable,
@@ -295,6 +387,9 @@ export class SharedMemory {
295
387
  perBufferMax,
296
388
  budget,
297
389
  chunkSize,
390
+ false,
391
+ chargeInitial,
392
+ buffers?.cursors?.stringTable,
298
393
  );
299
394
  this.arrayTable = makeBuffer(
300
395
  buffers?.arrayTable,
@@ -302,6 +397,9 @@ export class SharedMemory {
302
397
  perBufferMax,
303
398
  budget,
304
399
  chunkSize,
400
+ false,
401
+ chargeInitial,
402
+ buffers?.cursors?.arrayTable,
305
403
  );
306
404
  this.versionView = new Int32Array(this.indexTable.buffers[0]!);
307
405
  this.layoutVersion = new Int32Array(
@@ -319,6 +417,24 @@ export class SharedMemory {
319
417
  return this.lock.withLock(fn);
320
418
  }
321
419
 
420
+ /**
421
+ * Apply a buffer update received from the main thread after a
422
+ * defragmentation pass. Replaces each table's buffers and rebuilds the
423
+ * version view. Called by workers on the `__chunkUpdate` message.
424
+ */
425
+ applyChunkUpdate(buffers: {
426
+ indexTable: SharedArrayBuffer[];
427
+ dataTable: SharedArrayBuffer[];
428
+ stringTable: SharedArrayBuffer[];
429
+ arrayTable: SharedArrayBuffer[];
430
+ }): void {
431
+ this.indexTable.applyChunks(buffers.indexTable);
432
+ this.dataTable.applyChunks(buffers.dataTable);
433
+ this.stringTable.applyChunks(buffers.stringTable);
434
+ this.arrayTable.applyChunks(buffers.arrayTable);
435
+ this.versionView = new Int32Array(this.indexTable.buffers[0]!);
436
+ }
437
+
322
438
  /**
323
439
  * Attach the reference-counting buffer (5th SAB) to this memory so it is
324
440
  * included in `exportBuffers()`. No-op if a buffer is already attached.
@@ -334,8 +450,10 @@ export class SharedMemory {
334
450
  * Export all shared buffers (including the lock) so they can be sent to a
335
451
  * worker via `postMessage` and reconstructed there with `fromBuffers()`.
336
452
  *
337
- * Each table is exported as a list of SharedArrayBuffers (chunks), since a
338
- * single SharedArrayBuffer is limited to 4 GiB.
453
+ * Tables are exported as lists for the chunked buffers. The Index Table is
454
+ * a single growable SharedArrayBuffer, exported as a one-element list. The
455
+ * optional shared budget counter is included so workers reserve atomically
456
+ * against the same three-table limit.
339
457
  */
340
458
  exportBuffers(): {
341
459
  indexTable: SharedArrayBuffer[];
@@ -345,6 +463,16 @@ export class SharedMemory {
345
463
  layoutVersion: SharedArrayBuffer;
346
464
  lock: SharedArrayBuffer;
347
465
  refCount: SharedArrayBuffer | null;
466
+ maxByteLength: number;
467
+ indexTableMaxByteLength: number;
468
+ maxTotalBytes: number | null;
469
+ budget: SharedArrayBuffer | null;
470
+ cursors: {
471
+ indexTable: SharedArrayBuffer;
472
+ dataTable: SharedArrayBuffer;
473
+ stringTable: SharedArrayBuffer;
474
+ arrayTable: SharedArrayBuffer;
475
+ };
348
476
  } {
349
477
  return {
350
478
  indexTable: this.indexTable.buffers,
@@ -354,6 +482,16 @@ export class SharedMemory {
354
482
  layoutVersion: this.layoutVersion.buffer as SharedArrayBuffer,
355
483
  lock: this.lock.buffer as SharedArrayBuffer,
356
484
  refCount: this.refCountBuffer,
485
+ maxByteLength: this.maxByteLength,
486
+ indexTableMaxByteLength: this.indexTableMaxByteLength,
487
+ maxTotalBytes: this.maxTotalBytes,
488
+ budget: this.budgetBuffer,
489
+ cursors: {
490
+ indexTable: this.indexTable.cursorBuffer,
491
+ dataTable: this.dataTable.cursorBuffer,
492
+ stringTable: this.stringTable.cursorBuffer,
493
+ arrayTable: this.arrayTable.cursorBuffer,
494
+ },
357
495
  };
358
496
  }
359
497
 
@@ -369,8 +507,28 @@ export class SharedMemory {
369
507
  layoutVersion: SharedArrayBuffer;
370
508
  lock: SharedArrayBuffer;
371
509
  refCount?: SharedArrayBuffer | null;
510
+ maxByteLength?: number;
511
+ indexTableMaxByteLength?: number;
512
+ maxTotalBytes?: number | null;
513
+ budget?: SharedArrayBuffer | null;
514
+ cursors?: {
515
+ indexTable?: SharedArrayBuffer;
516
+ dataTable?: SharedArrayBuffer;
517
+ stringTable?: SharedArrayBuffer;
518
+ arrayTable?: SharedArrayBuffer;
519
+ };
372
520
  }): SharedMemory {
521
+ const indexBuffer = Array.isArray(buffers.indexTable)
522
+ ? buffers.indexTable[0]
523
+ : buffers.indexTable;
373
524
  return new SharedMemory({
525
+ maxByteLength: buffers.maxByteLength ?? DEFAULT_MAX,
526
+ indexTableMaxByteLength:
527
+ buffers.indexTableMaxByteLength ??
528
+ indexBuffer?.maxByteLength ??
529
+ indexBuffer?.byteLength ??
530
+ DEFAULT_MAX,
531
+ maxTotalBytes: buffers.maxTotalBytes ?? undefined,
374
532
  buffers: {
375
533
  indexTable: buffers.indexTable,
376
534
  dataTable: buffers.dataTable,
@@ -378,6 +536,8 @@ export class SharedMemory {
378
536
  arrayTable: buffers.arrayTable,
379
537
  layoutVersion: buffers.layoutVersion,
380
538
  refCount: buffers.refCount ?? undefined,
539
+ budget: buffers.budget ?? undefined,
540
+ cursors: buffers.cursors,
381
541
  },
382
542
  lock: new Mutex(buffers.lock),
383
543
  });
@@ -798,7 +958,12 @@ export class SharedMemory {
798
958
  *
799
959
  * Must be called under the shared lock (or on a single thread).
800
960
  */
801
- defragment(): void {
961
+ defragment(): {
962
+ indexTable: SharedArrayBuffer[];
963
+ dataTable: SharedArrayBuffer[];
964
+ stringTable: SharedArrayBuffer[];
965
+ arrayTable: SharedArrayBuffer[];
966
+ } {
802
967
  const stringRemap = new Map<number, number>();
803
968
  const arrayRemap = new Map<number, number>();
804
969
  const objectRemap = new Map<number, number>();
@@ -964,6 +1129,15 @@ export class SharedMemory {
964
1129
 
965
1130
  // Phase 7: Invalidate all per-worker caches.
966
1131
  this.bumpLayoutVersion();
1132
+
1133
+ // Phase 8: Reclaim memory from inactive chunks (beyond the compacted
1134
+ // region). The Index Table is a single growable SAB and is never replaced.
1135
+ return {
1136
+ indexTable: this.indexTable.releaseInactiveChunks(),
1137
+ dataTable: this.dataTable.releaseInactiveChunks(),
1138
+ stringTable: this.stringTable.releaseInactiveChunks(),
1139
+ arrayTable: this.arrayTable.releaseInactiveChunks(),
1140
+ };
967
1141
  }
968
1142
  }
969
1143
 
package/src/refcount.ts CHANGED
@@ -161,6 +161,13 @@ export class RefCountCoordinator {
161
161
  /** Free-space threshold (fraction of maxByteLength) that triggers defrag. */
162
162
  private readonly defragThreshold: number;
163
163
  private maintenanceTimer: ReturnType<typeof setInterval> | null = null;
164
+ /** Called after a defragmentation pass with the new chunk lists. */
165
+ private readonly onDefragment?: (buffers: {
166
+ indexTable: SharedArrayBuffer[];
167
+ dataTable: SharedArrayBuffer[];
168
+ stringTable: SharedArrayBuffer[];
169
+ arrayTable: SharedArrayBuffer[];
170
+ }) => void;
164
171
 
165
172
  constructor(
166
173
  mem: SharedMemory,
@@ -169,9 +176,16 @@ export class RefCountCoordinator {
169
176
  defragThreshold?: number;
170
177
  autoMaintenance?: boolean;
171
178
  maintenanceInterval?: number;
179
+ onDefragment?: (buffers: {
180
+ indexTable: SharedArrayBuffer[];
181
+ dataTable: SharedArrayBuffer[];
182
+ stringTable: SharedArrayBuffer[];
183
+ arrayTable: SharedArrayBuffer[];
184
+ }) => void;
172
185
  } = {},
173
186
  ) {
174
187
  this.mem = mem;
188
+ this.onDefragment = opts.onDefragment;
175
189
  // Reuse the memory's shared refCount buffer if present, so the same SAB is
176
190
  // exported to workers; otherwise create a fresh one and attach it to the
177
191
  // memory so it can be shared.
@@ -313,7 +327,8 @@ export class RefCountCoordinator {
313
327
  this.maintenanceTimer = setInterval(() => {
314
328
  this.drainEvents();
315
329
  if (this.needsDefrag()) {
316
- this.mem.withLock(() => this.mem.defragment());
330
+ const buffers = this.mem.withLock(() => this.mem.defragment());
331
+ this.onDefragment?.(buffers);
317
332
  }
318
333
  }, intervalMs);
319
334
  // Unref the timer so it does not keep the process alive. The coordinator
package/src/worker.ts CHANGED
@@ -130,7 +130,9 @@ export function getDefaultTracker(): RefCountTracker | null {
130
130
  function startCoordinator(): void {
131
131
  if (coordinator !== null) return;
132
132
  const mem = getDefaultMemory();
133
- coordinator = new RefCountCoordinator(mem);
133
+ coordinator = new RefCountCoordinator(mem, undefined, {
134
+ onDefragment: (buffers) => broadcastChunkUpdate(buffers),
135
+ });
134
136
  unregisterWorkers = coordinator.autoRegister();
135
137
  }
136
138
 
@@ -156,6 +158,12 @@ let mainListener:
156
158
  }) => void)
157
159
  | null = null;
158
160
 
161
+ /** Live workers on the main thread, for broadcasting chunk updates. */
162
+ const liveWorkers = new Set<{
163
+ postMessage: (msg: unknown) => void;
164
+ addEventListener: (type: string, fn: (e: { data: unknown }) => void) => void;
165
+ }>();
166
+
159
167
  /** Main thread: answer each worker's buffer request. */
160
168
  function initMain(): void {
161
169
  const listener = (worker: {
@@ -165,6 +173,7 @@ function initMain(): void {
165
173
  fn: (e: { data: unknown }) => void,
166
174
  ) => void;
167
175
  }) => {
176
+ liveWorkers.add(worker);
168
177
  worker.addEventListener("message", (e) => {
169
178
  const msg = e.data as { __sharedMemoryRequest?: boolean } | undefined;
170
179
  if (
@@ -191,6 +200,22 @@ function stopMain(): void {
191
200
  process.off("worker", mainListener);
192
201
  mainListener = null;
193
202
  }
203
+ liveWorkers.clear();
204
+ }
205
+
206
+ /**
207
+ * Broadcast a chunk update (new chunk lists after a defragmentation pass) to
208
+ * all live workers. Workers apply it via `applyChunkUpdate()`.
209
+ */
210
+ function broadcastChunkUpdate(buffers: {
211
+ indexTable: SharedArrayBuffer[];
212
+ dataTable: SharedArrayBuffer[];
213
+ stringTable: SharedArrayBuffer[];
214
+ arrayTable: SharedArrayBuffer[];
215
+ }): void {
216
+ for (const worker of liveWorkers) {
217
+ worker.postMessage({ __chunkUpdate: true, buffers });
218
+ }
194
219
  }
195
220
 
196
221
  /** Worker: request the buffers from the main thread and await the reply. */
@@ -239,6 +264,15 @@ function installMessageWrapper(): void {
239
264
  __sharedMemoryInit?: boolean;
240
265
  buffers?: Parameters<typeof SharedMemory.fromBuffers>[0];
241
266
  }
267
+ | {
268
+ __chunkUpdate?: boolean;
269
+ buffers?: {
270
+ indexTable: SharedArrayBuffer[];
271
+ dataTable: SharedArrayBuffer[];
272
+ stringTable: SharedArrayBuffer[];
273
+ arrayTable: SharedArrayBuffer[];
274
+ };
275
+ }
242
276
  | undefined;
243
277
  if (
244
278
  msg !== null &&
@@ -264,6 +298,18 @@ function installMessageWrapper(): void {
264
298
  flush();
265
299
  return;
266
300
  }
301
+ if (
302
+ msg !== null &&
303
+ typeof msg === "object" &&
304
+ "__chunkUpdate" in msg &&
305
+ msg.buffers &&
306
+ hasDefaultMemory()
307
+ ) {
308
+ // The main thread replaced inactive chunks after a defragmentation
309
+ // pass; apply the new chunk lists so this worker sees the replacement.
310
+ getDefaultMemory().applyChunkUpdate(msg.buffers);
311
+ return;
312
+ }
267
313
  if (hasDefaultMemory() && hasHandler) {
268
314
  messageHandler(e);
269
315
  } else {