bun-shared-memory 0.1.6 → 0.1.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.
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.7",
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.
34
+ */
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.
28
52
  */
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;
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).
39
- *
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).
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.
44
70
  *
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,87 @@ 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
105
  private used = 0;
106
+ /** Smallest chunk index that is not yet active (see `activate`). */
107
+ private nextChunkToActivate = 0;
75
108
 
76
109
  constructor(
77
110
  initialSizeOrBuffer: number | SharedArrayBuffer | SharedArrayBuffer[],
78
111
  maxByteLength: number,
79
112
  budget?: SharedBudget,
80
113
  chunkSize = 32 * 1024 * 1024, // 32 MiB per chunk
114
+ singleBuffer = false,
115
+ chargeInitial = true,
81
116
  ) {
82
- this.maxByteLength = maxByteLength;
117
+ this.singleBuffer = singleBuffer;
83
118
  this.budget = budget;
119
+ if (singleBuffer && typeof initialSizeOrBuffer !== "number") {
120
+ const buffer = Array.isArray(initialSizeOrBuffer)
121
+ ? initialSizeOrBuffer[0]
122
+ : initialSizeOrBuffer;
123
+ if (
124
+ buffer === undefined ||
125
+ (Array.isArray(initialSizeOrBuffer) && initialSizeOrBuffer.length !== 1)
126
+ ) {
127
+ throw new RangeError(
128
+ "A single-buffer GrowableBuffer requires exactly one SharedArrayBuffer",
129
+ );
130
+ }
131
+ this.maxByteLength = Math.min(
132
+ buffer.maxByteLength ?? buffer.byteLength,
133
+ maxByteLength,
134
+ );
135
+ this.chunkSize = this.maxByteLength;
136
+ this.chunkShift = 0;
137
+ this.chunks.push({
138
+ buffer,
139
+ view: new DataView(buffer),
140
+ active: buffer.byteLength > 4,
141
+ });
142
+ if (chargeInitial) this.budget?.add(buffer.byteLength);
143
+ return;
144
+ }
145
+
146
+ this.maxByteLength = maxByteLength;
84
147
  if (typeof initialSizeOrBuffer === "number") {
85
- this.chunkSize = Math.min(nextPowerOfTwo(chunkSize), maxByteLength);
148
+ if (singleBuffer) {
149
+ const buffer = new SharedArrayBuffer(4, {
150
+ maxByteLength,
151
+ });
152
+ this.chunkSize = maxByteLength;
153
+ this.chunkShift = 0;
154
+ this.chunks.push({
155
+ buffer,
156
+ view: new DataView(buffer),
157
+ active: false,
158
+ });
159
+ return;
160
+ }
161
+ this.chunkSize = Math.min(
162
+ nextPowerOfTwo(chunkSize),
163
+ largestPowerOfTwo(maxByteLength),
164
+ );
86
165
  this.chunkShift = Math.log2(this.chunkSize);
87
166
  // Pre-allocate all chunks (4 bytes each, growable to chunkSize).
88
167
  const chunkCount = Math.max(1, Math.ceil(maxByteLength / this.chunkSize));
@@ -95,6 +174,7 @@ export class GrowableBuffer {
95
174
  view: new DataView(buffer),
96
175
  active: false,
97
176
  });
177
+ if (chargeInitial) this.budget?.add(buffer.byteLength);
98
178
  }
99
179
  } else if (Array.isArray(initialSizeOrBuffer)) {
100
180
  // Reconstruct from a list of chunks. Use the buffers directly (they are
@@ -110,7 +190,7 @@ export class GrowableBuffer {
110
190
  view: new DataView(sab),
111
191
  active: sab.byteLength === this.chunkSize,
112
192
  });
113
- this.budget?.add(sab.byteLength);
193
+ if (chargeInitial) this.budget?.add(sab.byteLength);
114
194
  }
115
195
  } else {
116
196
  // Reconstruct from a single existing buffer. Use it directly so writes
@@ -124,7 +204,7 @@ export class GrowableBuffer {
124
204
  view: new DataView(initialSizeOrBuffer),
125
205
  active: initialSizeOrBuffer.byteLength === this.chunkSize,
126
206
  });
127
- this.budget?.add(initialSizeOrBuffer.byteLength);
207
+ if (chargeInitial) this.budget?.add(initialSizeOrBuffer.byteLength);
128
208
  }
129
209
  }
130
210
 
@@ -136,10 +216,53 @@ export class GrowableBuffer {
136
216
  private activate(index: number): void {
137
217
  const chunk = this.chunks[index];
138
218
  if (chunk === undefined || chunk.active) return;
139
- chunk.buffer.grow(this.chunkSize);
219
+ if (this.singleBuffer) {
220
+ return;
221
+ }
222
+ const oldSize = chunk.buffer.byteLength;
223
+ if (oldSize >= this.chunkSize) {
224
+ chunk.view = new DataView(chunk.buffer);
225
+ chunk.active = true;
226
+ this.advanceNextChunkToActivate(index);
227
+ return;
228
+ }
229
+ const growth = this.chunkSize - oldSize;
230
+ if (this.budget && !this.budget.tryReserve(growth)) {
231
+ throw new RangeError(
232
+ `Shared memory budget exceeded: need ${growth} additional bytes`,
233
+ );
234
+ }
235
+ try {
236
+ chunk.buffer.grow(this.chunkSize);
237
+ } catch (error) {
238
+ if (chunk.buffer.byteLength >= this.chunkSize) {
239
+ this.budget?.release(growth);
240
+ chunk.view = new DataView(chunk.buffer);
241
+ chunk.active = true;
242
+ this.advanceNextChunkToActivate(index);
243
+ return;
244
+ }
245
+ this.budget?.release(growth);
246
+ throw error;
247
+ }
140
248
  chunk.view = new DataView(chunk.buffer);
141
249
  chunk.active = true;
142
- this.budget?.add(this.chunkSize);
250
+ this.advanceNextChunkToActivate(index);
251
+ }
252
+
253
+ /**
254
+ * Advance `nextChunkToActivate` past `index` while the following chunks are
255
+ * already active, so `ensureCapacity` never re-scans active chunks.
256
+ */
257
+ private advanceNextChunkToActivate(index: number): void {
258
+ if (index < this.nextChunkToActivate) return;
259
+ this.nextChunkToActivate = index + 1;
260
+ while (
261
+ this.nextChunkToActivate < this.chunks.length &&
262
+ this.chunks[this.nextChunkToActivate]!.active
263
+ ) {
264
+ this.nextChunkToActivate++;
265
+ }
143
266
  }
144
267
 
145
268
  /** Number of bytes written so far (used length, not buffer capacity). */
@@ -149,7 +272,9 @@ export class GrowableBuffer {
149
272
 
150
273
  /** Total logical capacity across all chunks (active or not). */
151
274
  get capacity(): number {
152
- return this.chunks.length * this.chunkSize;
275
+ return this.singleBuffer
276
+ ? this.maxByteLength
277
+ : this.chunks.length * this.chunkSize;
153
278
  }
154
279
 
155
280
  /** The list of SharedArrayBuffers backing this buffer (for export). */
@@ -157,18 +282,43 @@ export class GrowableBuffer {
157
282
  return this.chunks.map((c) => c.buffer);
158
283
  }
159
284
 
160
- /** Number of chunks that have been activated (grown to full size). */
285
+ /** Number of chunks that have been grown from their initial 4-byte size. */
161
286
  get activeChunkCount(): number {
162
287
  return this.chunks.reduce((n, c) => n + (c.active ? 1 : 0), 0);
163
288
  }
164
289
 
290
+ /**
291
+ * Replace the chunk list with `newBuffers` (received from the main thread
292
+ * after a defragmentation broadcast). Each buffer is either a full-size
293
+ * active chunk or a fresh 4-byte inactive chunk. Used by workers to stay in
294
+ * sync with the main thread's chunk replacement.
295
+ */
296
+ applyChunks(newBuffers: SharedArrayBuffer[]): void {
297
+ if (this.singleBuffer && newBuffers.length !== 1) {
298
+ throw new RangeError(
299
+ "A single-buffer GrowableBuffer requires exactly one SharedArrayBuffer",
300
+ );
301
+ }
302
+ this.chunks = newBuffers.map((sab) => ({
303
+ buffer: sab,
304
+ view: new DataView(sab),
305
+ active: this.singleBuffer
306
+ ? sab.byteLength > 4
307
+ : sab.byteLength === this.chunkSize,
308
+ }));
309
+ this.nextChunkToActivate = this.firstInactiveChunk();
310
+ }
311
+
165
312
  /**
166
313
  * Resolve a logical offset into (chunk, offset within that chunk). All
167
314
  * chunks are the same size, so this is O(1). Activates the chunk if it is
168
315
  * not yet active.
169
316
  */
170
317
  private resolve(offset: number): { chunk: Chunk; off: number } {
171
- const index = offset >> this.chunkShift;
318
+ if (this.singleBuffer) {
319
+ return { chunk: this.chunks[0]!, off: offset };
320
+ }
321
+ const index = offset >>> this.chunkShift;
172
322
  const chunk = this.chunks[index];
173
323
  if (chunk === undefined) {
174
324
  throw new RangeError(`Offset ${offset} is beyond the buffer capacity`);
@@ -184,10 +334,39 @@ export class GrowableBuffer {
184
334
  `Shared buffer capacity exceeded: need ${needed} bytes but max is ${this.maxByteLength}`,
185
335
  );
186
336
  }
337
+ if (this.singleBuffer) {
338
+ const chunk = this.chunks[0]!;
339
+ const oldSize = chunk.buffer.byteLength;
340
+ if (needed <= oldSize) return;
341
+ const target = Math.min(
342
+ this.maxByteLength,
343
+ Math.max(needed, oldSize * 2),
344
+ );
345
+ let growth = target - oldSize;
346
+ if (this.budget && !this.budget.tryReserve(growth)) {
347
+ if (target === needed || !this.budget.tryReserve(needed - oldSize)) {
348
+ throw new RangeError(
349
+ `Shared memory budget exceeded: need ${needed - oldSize} additional bytes`,
350
+ );
351
+ }
352
+ growth = needed - oldSize;
353
+ }
354
+ try {
355
+ chunk.buffer.grow(oldSize + growth);
356
+ } catch (error) {
357
+ this.budget?.release(growth);
358
+ throw error;
359
+ }
360
+ chunk.view = new DataView(chunk.buffer);
361
+ chunk.active = true;
362
+ return;
363
+ }
187
364
  // All chunks are pre-allocated; just activate the ones needed to cover
188
365
  // `needed`. grow() is visible across workers, so no broadcast is required.
366
+ // Start from `nextChunkToActivate` so already-active chunks are not
367
+ // re-scanned on every write (keeps the write hot path O(1) amortized).
189
368
  const neededChunks = Math.ceil(needed / this.chunkSize);
190
- for (let i = 0; i < neededChunks; i++) {
369
+ for (let i = this.nextChunkToActivate; i < neededChunks; i++) {
191
370
  this.activate(i);
192
371
  }
193
372
  }
@@ -200,15 +379,54 @@ export class GrowableBuffer {
200
379
  /**
201
380
  * Reset the used length to `n` bytes. Used by defragmentation after
202
381
  * 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).
382
+ * compacted region are marked inactive; their storage is reclaimed by
383
+ * `releaseInactiveChunks()`.
205
384
  */
206
385
  reset(n: number): void {
207
386
  this.used = n;
387
+ if (this.singleBuffer) return;
208
388
  const neededChunks = Math.ceil(n / this.chunkSize);
209
- for (let i = neededChunks; i < this.chunks.length; i++) {
210
- this.chunks[i]!.active = false;
389
+ for (let i = 0; i < this.chunks.length; i++) {
390
+ const chunk = this.chunks[i]!;
391
+ chunk.active =
392
+ i < neededChunks && chunk.buffer.byteLength >= this.chunkSize;
211
393
  }
394
+ this.nextChunkToActivate = this.firstInactiveChunk();
395
+ }
396
+
397
+ /**
398
+ * Replace every inactive chunk with a fresh 4-byte SharedArrayBuffer
399
+ * (growable to `chunkSize`), releasing the old full-size buffer so its
400
+ * memory can be reclaimed. Returns the new chunk list; the caller must
401
+ * broadcast it to workers so they see the replacement.
402
+ *
403
+ * Because a SharedArrayBuffer cannot be shrunk, reclaiming memory requires
404
+ * allocating a new buffer. This is only called after defragmentation (rare),
405
+ * so the resulting broadcast is acceptable.
406
+ */
407
+ releaseInactiveChunks(): SharedArrayBuffer[] {
408
+ if (this.singleBuffer) return this.chunks.map((c) => c.buffer);
409
+ for (let i = 0; i < this.chunks.length; i++) {
410
+ const chunk = this.chunks[i]!;
411
+ if (chunk.active) continue;
412
+ // Release the old full-size buffer from the budget and replace it with
413
+ // a fresh 4-byte chunk.
414
+ this.budget?.release(Math.max(0, chunk.buffer.byteLength - 4));
415
+ const buffer = new SharedArrayBuffer(4, {
416
+ maxByteLength: this.chunkSize,
417
+ });
418
+ this.chunks[i] = { buffer, view: new DataView(buffer), active: false };
419
+ }
420
+ this.nextChunkToActivate = this.firstInactiveChunk();
421
+ return this.chunks.map((c) => c.buffer);
422
+ }
423
+
424
+ /** Index of the first chunk that is not active, or `chunks.length`. */
425
+ private firstInactiveChunk(): number {
426
+ for (let i = 0; i < this.chunks.length; i++) {
427
+ if (!this.chunks[i]!.active) return i;
428
+ }
429
+ return this.chunks.length;
212
430
  }
213
431
 
214
432
  // -------------------------------------------------------------------------
@@ -216,7 +434,8 @@ export class GrowableBuffer {
216
434
  // -------------------------------------------------------------------------
217
435
 
218
436
  getInt32(offset: number): number {
219
- const index = offset >> this.chunkShift;
437
+ if (this.singleBuffer) return this.chunks[0]!.view.getInt32(offset, true);
438
+ const index = offset >>> this.chunkShift;
220
439
  this.activate(index);
221
440
  return this.chunks[index]!.view.getInt32(
222
441
  offset & (this.chunkSize - 1),
@@ -225,7 +444,11 @@ export class GrowableBuffer {
225
444
  }
226
445
 
227
446
  setInt32(offset: number, value: number): void {
228
- const index = offset >> this.chunkShift;
447
+ if (this.singleBuffer) {
448
+ this.chunks[0]!.view.setInt32(offset, value, true);
449
+ return;
450
+ }
451
+ const index = offset >>> this.chunkShift;
229
452
  this.activate(index);
230
453
  this.chunks[index]!.view.setInt32(
231
454
  offset & (this.chunkSize - 1),
@@ -235,13 +458,18 @@ export class GrowableBuffer {
235
458
  }
236
459
 
237
460
  getUint8(offset: number): number {
238
- const index = offset >> this.chunkShift;
461
+ if (this.singleBuffer) return this.chunks[0]!.view.getUint8(offset);
462
+ const index = offset >>> this.chunkShift;
239
463
  this.activate(index);
240
464
  return this.chunks[index]!.view.getUint8(offset & (this.chunkSize - 1));
241
465
  }
242
466
 
243
467
  setUint8(offset: number, value: number): void {
244
- const index = offset >> this.chunkShift;
468
+ if (this.singleBuffer) {
469
+ this.chunks[0]!.view.setUint8(offset, value);
470
+ return;
471
+ }
472
+ const index = offset >>> this.chunkShift;
245
473
  this.activate(index);
246
474
  this.chunks[index]!.view.setUint8(offset & (this.chunkSize - 1), value);
247
475
  }
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,7 @@ export interface SharedMemoryOptions {
187
210
  arrayTable?: SharedArrayBuffer | SharedArrayBuffer[];
188
211
  layoutVersion?: SharedArrayBuffer;
189
212
  refCount?: SharedArrayBuffer;
213
+ budget?: SharedArrayBuffer;
190
214
  };
191
215
  }
192
216
 
@@ -197,11 +221,27 @@ function makeBuffer(
197
221
  maxByteLength: number,
198
222
  budget?: SharedBudget,
199
223
  chunkSize?: number,
224
+ singleBuffer = false,
225
+ chargeInitial = true,
200
226
  ): GrowableBuffer {
201
227
  if (buf === undefined) {
202
- return new GrowableBuffer(initial, maxByteLength, budget, chunkSize);
228
+ return new GrowableBuffer(
229
+ initial,
230
+ maxByteLength,
231
+ budget,
232
+ chunkSize,
233
+ singleBuffer,
234
+ chargeInitial,
235
+ );
203
236
  }
204
- return new GrowableBuffer(buf, maxByteLength, budget, chunkSize);
237
+ return new GrowableBuffer(
238
+ buf,
239
+ maxByteLength,
240
+ budget,
241
+ chunkSize,
242
+ singleBuffer,
243
+ chargeInitial,
244
+ );
205
245
  }
206
246
 
207
247
  /**
@@ -213,6 +253,12 @@ export class SharedMemory {
213
253
  readonly dataTable: GrowableBuffer;
214
254
  readonly stringTable: GrowableBuffer;
215
255
  readonly arrayTable: GrowableBuffer;
256
+ /** Per-buffer maximum byte length (clamped to at least 32 MiB). */
257
+ readonly maxByteLength: number;
258
+ private readonly indexTableMaxByteLength: number;
259
+ /** Combined memory budget for Data/String/Array tables, or null if unset. */
260
+ readonly maxTotalBytes: number | null;
261
+ private readonly budgetBuffer: SharedArrayBuffer | null;
216
262
  /**
217
263
  * Cached Int32Array view over the Index Table, used to read/increment each
218
264
  * key's version cell (stored as the 4th field of each index entry). Rebuilt
@@ -262,25 +308,57 @@ export class SharedMemory {
262
308
 
263
309
  constructor(options?: SharedMemoryOptions) {
264
310
  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.
311
+ // `maxTotalBytes` caps the combined physical size of DataTable,
312
+ // StringTable, and ArrayTable. IndexTable has its independent 4 GiB cap.
268
313
  // 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.
314
+ // The Index Table remains a single growable SharedArrayBuffer and is
315
+ // capped at 4 GiB. The other tables use chunks and can exceed that limit.
272
316
  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;
317
+ if (
318
+ maxTotal !== undefined &&
319
+ (!Number.isSafeInteger(maxTotal) || maxTotal < 0)
320
+ ) {
321
+ throw new RangeError("maxTotalBytes must be a non-negative safe integer");
322
+ }
323
+ // Enforce the minimum memory budget: maxTotalBytes / maxByteLength are
324
+ // never below 32 MiB.
325
+ const perBufferMax = Math.max(
326
+ maxTotal ?? options?.maxByteLength ?? DEFAULT_MAX,
327
+ MIN_MAX_MEMORY,
328
+ );
329
+ const indexTableMax = Math.min(
330
+ options?.indexTableMaxByteLength ?? options?.maxByteLength ?? DEFAULT_MAX,
331
+ MAX_INDEX_TABLE_BYTES,
332
+ );
333
+ // Enforce the chunk-size limits: at least 4 MiB, and large enough that no
334
+ // table needs more than MAX_CHUNKS_PER_TABLE chunks.
335
+ const userChunkSize = options?.chunkSize ?? DEFAULT_CHUNK_SIZE;
336
+ const chunkSize = Math.max(
337
+ userChunkSize,
338
+ MIN_CHUNK_SIZE,
339
+ nextPowerOfTwo(perBufferMax / MAX_CHUNKS_PER_TABLE),
340
+ );
277
341
  const buffers = options?.buffers;
342
+ // The budget is the clamped maxTotalBytes (never below MIN_MAX_MEMORY), so
343
+ // it stays consistent with perBufferMax.
344
+ const budgetMax = maxTotal !== undefined ? perBufferMax : undefined;
345
+ const budgetBuffer = buffers?.budget;
346
+ const budget =
347
+ budgetMax !== undefined
348
+ ? new SharedBudget(budgetMax, budgetBuffer)
349
+ : undefined;
350
+ const chargeInitial = budgetBuffer === undefined;
351
+ this.maxByteLength = perBufferMax;
352
+ this.indexTableMaxByteLength = indexTableMax;
353
+ this.maxTotalBytes = budgetMax ?? null;
354
+ this.budgetBuffer = budget?.buffer ?? null;
278
355
  this.indexTable = makeBuffer(
279
356
  buffers?.indexTable,
280
357
  initial,
281
- perBufferMax,
282
- budget,
358
+ indexTableMax,
359
+ undefined,
283
360
  chunkSize,
361
+ true,
284
362
  );
285
363
  this.dataTable = makeBuffer(
286
364
  buffers?.dataTable,
@@ -288,6 +366,8 @@ export class SharedMemory {
288
366
  perBufferMax,
289
367
  budget,
290
368
  chunkSize,
369
+ false,
370
+ chargeInitial,
291
371
  );
292
372
  this.stringTable = makeBuffer(
293
373
  buffers?.stringTable,
@@ -295,6 +375,8 @@ export class SharedMemory {
295
375
  perBufferMax,
296
376
  budget,
297
377
  chunkSize,
378
+ false,
379
+ chargeInitial,
298
380
  );
299
381
  this.arrayTable = makeBuffer(
300
382
  buffers?.arrayTable,
@@ -302,6 +384,8 @@ export class SharedMemory {
302
384
  perBufferMax,
303
385
  budget,
304
386
  chunkSize,
387
+ false,
388
+ chargeInitial,
305
389
  );
306
390
  this.versionView = new Int32Array(this.indexTable.buffers[0]!);
307
391
  this.layoutVersion = new Int32Array(
@@ -319,6 +403,24 @@ export class SharedMemory {
319
403
  return this.lock.withLock(fn);
320
404
  }
321
405
 
406
+ /**
407
+ * Apply a buffer update received from the main thread after a
408
+ * defragmentation pass. Replaces each table's buffers and rebuilds the
409
+ * version view. Called by workers on the `__chunkUpdate` message.
410
+ */
411
+ applyChunkUpdate(buffers: {
412
+ indexTable: SharedArrayBuffer[];
413
+ dataTable: SharedArrayBuffer[];
414
+ stringTable: SharedArrayBuffer[];
415
+ arrayTable: SharedArrayBuffer[];
416
+ }): void {
417
+ this.indexTable.applyChunks(buffers.indexTable);
418
+ this.dataTable.applyChunks(buffers.dataTable);
419
+ this.stringTable.applyChunks(buffers.stringTable);
420
+ this.arrayTable.applyChunks(buffers.arrayTable);
421
+ this.versionView = new Int32Array(this.indexTable.buffers[0]!);
422
+ }
423
+
322
424
  /**
323
425
  * Attach the reference-counting buffer (5th SAB) to this memory so it is
324
426
  * included in `exportBuffers()`. No-op if a buffer is already attached.
@@ -334,8 +436,10 @@ export class SharedMemory {
334
436
  * Export all shared buffers (including the lock) so they can be sent to a
335
437
  * worker via `postMessage` and reconstructed there with `fromBuffers()`.
336
438
  *
337
- * Each table is exported as a list of SharedArrayBuffers (chunks), since a
338
- * single SharedArrayBuffer is limited to 4 GiB.
439
+ * Tables are exported as lists for the chunked buffers. The Index Table is
440
+ * a single growable SharedArrayBuffer, exported as a one-element list. The
441
+ * optional shared budget counter is included so workers reserve atomically
442
+ * against the same three-table limit.
339
443
  */
340
444
  exportBuffers(): {
341
445
  indexTable: SharedArrayBuffer[];
@@ -345,6 +449,10 @@ export class SharedMemory {
345
449
  layoutVersion: SharedArrayBuffer;
346
450
  lock: SharedArrayBuffer;
347
451
  refCount: SharedArrayBuffer | null;
452
+ maxByteLength: number;
453
+ indexTableMaxByteLength: number;
454
+ maxTotalBytes: number | null;
455
+ budget: SharedArrayBuffer | null;
348
456
  } {
349
457
  return {
350
458
  indexTable: this.indexTable.buffers,
@@ -354,6 +462,10 @@ export class SharedMemory {
354
462
  layoutVersion: this.layoutVersion.buffer as SharedArrayBuffer,
355
463
  lock: this.lock.buffer as SharedArrayBuffer,
356
464
  refCount: this.refCountBuffer,
465
+ maxByteLength: this.maxByteLength,
466
+ indexTableMaxByteLength: this.indexTableMaxByteLength,
467
+ maxTotalBytes: this.maxTotalBytes,
468
+ budget: this.budgetBuffer,
357
469
  };
358
470
  }
359
471
 
@@ -369,8 +481,22 @@ export class SharedMemory {
369
481
  layoutVersion: SharedArrayBuffer;
370
482
  lock: SharedArrayBuffer;
371
483
  refCount?: SharedArrayBuffer | null;
484
+ maxByteLength?: number;
485
+ indexTableMaxByteLength?: number;
486
+ maxTotalBytes?: number | null;
487
+ budget?: SharedArrayBuffer | null;
372
488
  }): SharedMemory {
489
+ const indexBuffer = Array.isArray(buffers.indexTable)
490
+ ? buffers.indexTable[0]
491
+ : buffers.indexTable;
373
492
  return new SharedMemory({
493
+ maxByteLength: buffers.maxByteLength ?? DEFAULT_MAX,
494
+ indexTableMaxByteLength:
495
+ buffers.indexTableMaxByteLength ??
496
+ indexBuffer?.maxByteLength ??
497
+ indexBuffer?.byteLength ??
498
+ DEFAULT_MAX,
499
+ maxTotalBytes: buffers.maxTotalBytes ?? undefined,
374
500
  buffers: {
375
501
  indexTable: buffers.indexTable,
376
502
  dataTable: buffers.dataTable,
@@ -378,6 +504,7 @@ export class SharedMemory {
378
504
  arrayTable: buffers.arrayTable,
379
505
  layoutVersion: buffers.layoutVersion,
380
506
  refCount: buffers.refCount ?? undefined,
507
+ budget: buffers.budget ?? undefined,
381
508
  },
382
509
  lock: new Mutex(buffers.lock),
383
510
  });
@@ -798,7 +925,12 @@ export class SharedMemory {
798
925
  *
799
926
  * Must be called under the shared lock (or on a single thread).
800
927
  */
801
- defragment(): void {
928
+ defragment(): {
929
+ indexTable: SharedArrayBuffer[];
930
+ dataTable: SharedArrayBuffer[];
931
+ stringTable: SharedArrayBuffer[];
932
+ arrayTable: SharedArrayBuffer[];
933
+ } {
802
934
  const stringRemap = new Map<number, number>();
803
935
  const arrayRemap = new Map<number, number>();
804
936
  const objectRemap = new Map<number, number>();
@@ -964,6 +1096,15 @@ export class SharedMemory {
964
1096
 
965
1097
  // Phase 7: Invalidate all per-worker caches.
966
1098
  this.bumpLayoutVersion();
1099
+
1100
+ // Phase 8: Reclaim memory from inactive chunks (beyond the compacted
1101
+ // region). The Index Table is a single growable SAB and is never replaced.
1102
+ return {
1103
+ indexTable: this.indexTable.releaseInactiveChunks(),
1104
+ dataTable: this.dataTable.releaseInactiveChunks(),
1105
+ stringTable: this.stringTable.releaseInactiveChunks(),
1106
+ arrayTable: this.arrayTable.releaseInactiveChunks(),
1107
+ };
967
1108
  }
968
1109
  }
969
1110
 
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 {