bun-shared-memory 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/mutex.ts ADDED
@@ -0,0 +1,92 @@
1
+ /**
2
+ * A cross-worker, re-entrant mutex built on Atomics.
3
+ *
4
+ * Uses a single Int32 cell in a SharedArrayBuffer. The cell encodes the lock
5
+ * state: 0 = free, 1 = held (no waiters), 2 = held (waiters present).
6
+ *
7
+ * `lock()` acquires the mutex with a compare-exchange spin, falling back to
8
+ * `Atomics.wait` so the thread sleeps instead of busy-spinning while the lock
9
+ * is held elsewhere. `unlock()` releases the cell and wakes waiting workers.
10
+ *
11
+ * The "2 = has waiters" encoding lets `unlock()` skip the (relatively
12
+ * expensive) `Atomics.notify` entirely when no other worker is waiting — the
13
+ * common uncontended case. When the cell was 2, all waiters are woken
14
+ * (`notify(Infinity)`); those that lose the race re-mark the cell as 2 and
15
+ * sleep again. The waiter re-checks the cell after marking it 2, so a release
16
+ * that happens between the failed acquire and the `wait` is not lost (the
17
+ * `wait` returns immediately with "not-equal" and the loop retries).
18
+ *
19
+ * The mutex is re-entrant: the same thread may acquire it multiple times
20
+ * (e.g. a locked operation that internally calls another locked operation)
21
+ * without deadlocking. Re-entrancy is tracked per-thread via a module-level
22
+ * depth map, which is naturally per-thread because each worker has its own
23
+ * module instance.
24
+ *
25
+ * The same Mutex instance (backed by the same SharedArrayBuffer) must be
26
+ * shared across all workers that access the protected data.
27
+ */
28
+ export class Mutex {
29
+ private readonly cell: Int32Array;
30
+
31
+ constructor(buffer?: SharedArrayBuffer) {
32
+ const sab = buffer ?? new SharedArrayBuffer(Int32Array.BYTES_PER_ELEMENT);
33
+ this.cell = new Int32Array(sab);
34
+ }
35
+
36
+ /** The underlying SharedArrayBuffer, for sharing with other workers. */
37
+ get buffer(): SharedArrayBuffer {
38
+ return this.cell.buffer as SharedArrayBuffer;
39
+ }
40
+
41
+ /** Acquire the mutex, blocking until it is available. */
42
+ lock(): void {
43
+ const depth = depths.get(this) ?? 0;
44
+ if (depth > 0) {
45
+ // Already held by this thread; just bump the depth.
46
+ depths.set(this, depth + 1);
47
+ return;
48
+ }
49
+ while (true) {
50
+ // Try to claim the lock: 0 (free) -> 1 (held, no waiters).
51
+ if (Atomics.compareExchange(this.cell, 0, 0, 1) === 0) {
52
+ depths.set(this, 1);
53
+ return;
54
+ }
55
+ // Lock is held elsewhere. Mark the cell as "has waiters" (1 -> 2), then
56
+ // sleep. If the lock was released between our failed acquire and this
57
+ // mark, the cell is 0 and `wait` returns immediately ("not-equal"), so
58
+ // the loop retries instead of sleeping forever.
59
+ Atomics.compareExchange(this.cell, 0, 1, 2);
60
+ Atomics.wait(this.cell, 0, 2);
61
+ }
62
+ }
63
+
64
+ /** Release the mutex and wake any waiting workers. */
65
+ unlock(): void {
66
+ const depth = depths.get(this) ?? 0;
67
+ if (depth <= 1) {
68
+ depths.delete(this);
69
+ // Release the lock. Only wake waiters if there were any (cell was 2);
70
+ // in the uncontended case (cell was 1) we skip the expensive notify.
71
+ const prev = Atomics.exchange(this.cell, 0, 0);
72
+ if (prev === 2) {
73
+ Atomics.notify(this.cell, 0, Infinity);
74
+ }
75
+ } else {
76
+ depths.set(this, depth - 1);
77
+ }
78
+ }
79
+
80
+ /** Run `fn` while holding the lock, releasing it afterwards. */
81
+ withLock<T>(fn: () => T): T {
82
+ this.lock();
83
+ try {
84
+ return fn();
85
+ } finally {
86
+ this.unlock();
87
+ }
88
+ }
89
+ }
90
+
91
+ /** Per-thread re-entrancy depth, keyed by Mutex instance. */
92
+ const depths = new WeakMap<Mutex, number>();
@@ -0,0 +1,39 @@
1
+ /**
2
+ * A cross-worker notification primitive built on Atomics.
3
+ *
4
+ * Wraps `Atomics.wait` / `Atomics.notify` over a single Int32 cell so workers
5
+ * can signal each other. A worker calls `wait()` to block until another
6
+ * worker calls `notify()`.
7
+ *
8
+ * The same Notifier instance (backed by the same SharedArrayBuffer) must be
9
+ * shared across all workers that need to communicate.
10
+ */
11
+ export class Notifier {
12
+ private readonly cell: Int32Array;
13
+
14
+ constructor(buffer?: SharedArrayBuffer) {
15
+ const sab = buffer ?? new SharedArrayBuffer(Int32Array.BYTES_PER_ELEMENT);
16
+ this.cell = new Int32Array(sab);
17
+ }
18
+
19
+ /** The underlying SharedArrayBuffer, for sharing with other workers. */
20
+ get buffer(): SharedArrayBuffer {
21
+ return this.cell.buffer as SharedArrayBuffer;
22
+ }
23
+
24
+ /**
25
+ * Block the current thread until `notify()` is called.
26
+ *
27
+ * Returns "ok" when woken by a notification, "not-equal" if the value
28
+ * changed before waiting, or "timed-out" if `timeoutMs` elapsed.
29
+ */
30
+ wait(timeoutMs?: number): "ok" | "not-equal" | "timed-out" {
31
+ return Atomics.wait(this.cell, 0, 0, timeoutMs);
32
+ }
33
+
34
+ /** Wake up to `count` workers blocked in `wait()`. */
35
+ notify(count = 1): number {
36
+ Atomics.store(this.cell, 0, 1);
37
+ return Atomics.notify(this.cell, 0, count);
38
+ }
39
+ }
@@ -0,0 +1,358 @@
1
+ import { SharedMemory } from "./memory.ts";
2
+
3
+ /**
4
+ * Reference counting for shared objects/arrays.
5
+ *
6
+ * The 4-buffer architecture is append-only: the DataTable, StringTable, and
7
+ * ArrayTable only grow, and dead space is never reclaimed. This module adds a
8
+ * reference counting system so that memory can be reclaimed when no worker
9
+ * references an object/array anymore.
10
+ *
11
+ * Roles:
12
+ * - **Main thread** (`RefCountCoordinator`): owns the global refcount table,
13
+ * tracks which worker holds a reference to which object/array, and runs the
14
+ * defragmentation pass when free space is low.
15
+ * - **Worker** (`RefCountTracker`): tracks its own local references and reports
16
+ * acquire/release events to the main thread. Uses a `FinalizationRegistry`
17
+ * so a reference is released when the returned JS object is GC'd.
18
+ *
19
+ * A worker that crashes cannot send a release event. The main thread detects
20
+ * this via the worker's `close` event and releases all of that worker's
21
+ * references.
22
+ */
23
+
24
+ /** A stable identity for a shared object or array. */
25
+ export interface ObjectId {
26
+ /** Monotonic id, stable across defragmentation. */
27
+ id: number;
28
+ /** 0 = object (DataTable), 1 = array (ArrayTable). */
29
+ kind: 0 | 1;
30
+ }
31
+
32
+ /** An acquire/release event sent from a worker to the main thread. */
33
+ export interface RefEvent {
34
+ /** The worker that sent the event. */
35
+ workerId: number;
36
+ /** 1 = acquire, 0 = release. */
37
+ op: 0 | 1;
38
+ /** The objectId being acquired or released. */
39
+ objectId: number;
40
+ }
41
+
42
+ /**
43
+ * The 5th shared buffer: a ring-buffer message queue for acquire/release
44
+ * events, a heartbeat table, and a global refcount table.
45
+ *
46
+ * Layout (all Int32):
47
+ * [0] write index (producer, workers)
48
+ * [1] read index (consumer, main thread)
49
+ * [2] next worker id (allocated by workers)
50
+ * [3..] ring buffer of events (workerId, op, objectId triples)
51
+ * [ringEnd..] heartbeat table (one cell per worker)
52
+ * [hbEnd..] global refcount table (one cell per objectId)
53
+ */
54
+ export class RefCountBuffer {
55
+ readonly buffer: SharedArrayBuffer;
56
+ private readonly view: Int32Array;
57
+ private readonly ringSize: number;
58
+ private readonly hbOffset: number;
59
+ private readonly refOffset: number;
60
+
61
+ constructor(
62
+ buffer?: SharedArrayBuffer,
63
+ opts: { ringSize?: number; maxWorkers?: number; maxObjects?: number } = {},
64
+ ) {
65
+ const ringSize = opts.ringSize ?? 1024;
66
+ const maxWorkers = opts.maxWorkers ?? 64;
67
+ const maxObjects = opts.maxObjects ?? 1 << 16;
68
+ this.ringSize = ringSize;
69
+ this.hbOffset = 3 + ringSize * 3;
70
+ this.refOffset = this.hbOffset + maxWorkers;
71
+ const total = this.refOffset + maxObjects;
72
+ this.buffer =
73
+ buffer ?? new SharedArrayBuffer(total * Int32Array.BYTES_PER_ELEMENT);
74
+ this.view = new Int32Array(this.buffer);
75
+ }
76
+
77
+ /** The underlying SharedArrayBuffer, for sharing with other threads. */
78
+ get sab(): SharedArrayBuffer {
79
+ return this.buffer;
80
+ }
81
+
82
+ /**
83
+ * Allocate a unique worker id (worker side). Each worker calls this once to
84
+ * obtain its id, which it uses for heartbeats and events.
85
+ */
86
+ allocateWorkerId(): number {
87
+ return Atomics.add(this.view, 2, 1);
88
+ }
89
+
90
+ /** Append an acquire/release event to the ring buffer (worker side). */
91
+ pushEvent(workerId: number, op: 0 | 1, objectId: number): void {
92
+ const write = Atomics.load(this.view, 0);
93
+ const read = Atomics.load(this.view, 1);
94
+ if (write - read >= this.ringSize) {
95
+ // Ring full; drop the event (main thread will reconcile via heartbeat).
96
+ return;
97
+ }
98
+ const base = 3 + (write % this.ringSize) * 3;
99
+ Atomics.store(this.view, base, workerId);
100
+ Atomics.store(this.view, base + 1, op);
101
+ Atomics.store(this.view, base + 2, objectId);
102
+ Atomics.store(this.view, 0, write + 1);
103
+ }
104
+
105
+ /** Pop the next event, or null if the queue is empty (main thread side). */
106
+ popEvent(): RefEvent | null {
107
+ const read = Atomics.load(this.view, 1);
108
+ const write = Atomics.load(this.view, 0);
109
+ if (read >= write) return null;
110
+ const base = 3 + (read % this.ringSize) * 3;
111
+ const workerId = Atomics.load(this.view, base);
112
+ const op = Atomics.load(this.view, base + 1) as 0 | 1;
113
+ const objectId = Atomics.load(this.view, base + 2);
114
+ Atomics.store(this.view, 1, read + 1);
115
+ return { workerId, op, objectId };
116
+ }
117
+
118
+ /**
119
+ * Update this worker's heartbeat cell. Uses a monotonic counter that fits in
120
+ * an Int32 (Date.now() overflows Int32), so the main thread can detect a
121
+ * worker that stopped heartbeating.
122
+ */
123
+ heartbeat(workerId: number): void {
124
+ const prev = Atomics.load(this.view, this.hbOffset + workerId);
125
+ Atomics.store(this.view, this.hbOffset + workerId, (prev + 1) | 0);
126
+ }
127
+
128
+ /** Read a worker's last heartbeat timestamp. */
129
+ lastHeartbeat(workerId: number): number {
130
+ return Atomics.load(this.view, this.hbOffset + workerId);
131
+ }
132
+
133
+ /** Increment the global refcount for an objectId. */
134
+ acquireGlobal(objectId: number): void {
135
+ Atomics.add(this.view, this.refOffset + objectId, 1);
136
+ }
137
+
138
+ /** Decrement the global refcount for an objectId. Returns the new count. */
139
+ releaseGlobal(objectId: number): number {
140
+ return Atomics.sub(this.view, this.refOffset + objectId, 1);
141
+ }
142
+
143
+ /** Read the global refcount for an objectId. */
144
+ refCount(objectId: number): number {
145
+ return Atomics.load(this.view, this.refOffset + objectId);
146
+ }
147
+ }
148
+
149
+ /**
150
+ * Main-thread coordinator: owns the global refcount table, tracks which worker
151
+ * holds a reference to which object/array, and runs defragmentation.
152
+ */
153
+ export class RefCountCoordinator {
154
+ private readonly buffer: RefCountBuffer;
155
+ private readonly mem: SharedMemory;
156
+ /** workerId -> Set of objectIds that worker holds. */
157
+ private readonly workerRefs = new Map<number, Set<number>>();
158
+ /** objectId -> Set of workerIds holding it. */
159
+ private readonly objectWorkers = new Map<number, Set<number>>();
160
+ private nextWorkerId = 0;
161
+ /** Free-space threshold (fraction of maxByteLength) that triggers defrag. */
162
+ private readonly defragThreshold: number;
163
+ private maintenanceTimer: ReturnType<typeof setInterval> | null = null;
164
+
165
+ constructor(
166
+ mem: SharedMemory,
167
+ buffer?: RefCountBuffer,
168
+ opts: {
169
+ defragThreshold?: number;
170
+ autoMaintenance?: boolean;
171
+ maintenanceInterval?: number;
172
+ } = {},
173
+ ) {
174
+ this.mem = mem;
175
+ // Reuse the memory's shared refCount buffer if present, so the same SAB is
176
+ // exported to workers; otherwise create a fresh one and attach it to the
177
+ // memory so it can be shared.
178
+ this.buffer =
179
+ buffer ??
180
+ (mem.refCountBuffer !== null
181
+ ? new RefCountBuffer(mem.refCountBuffer)
182
+ : (() => {
183
+ const b = new RefCountBuffer();
184
+ mem.attachRefCountBuffer(b.sab);
185
+ return b;
186
+ })());
187
+ this.defragThreshold = opts.defragThreshold ?? 0.1;
188
+ // Start periodic maintenance automatically so refcount events are drained
189
+ // and dead space is reclaimed without an explicit call.
190
+ if (opts.autoMaintenance ?? true) {
191
+ this.startAutoMaintenance(opts.maintenanceInterval ?? 100);
192
+ }
193
+ }
194
+
195
+ get sab(): SharedArrayBuffer {
196
+ return this.buffer.sab;
197
+ }
198
+
199
+ /** Register a worker and attach a close listener that frees its refs. */
200
+ registerWorker(worker: {
201
+ onclose?: (() => void) | null;
202
+ addEventListener?: (type: string, fn: () => void) => void;
203
+ }): number {
204
+ const workerId = this.nextWorkerId++;
205
+ this.workerRefs.set(workerId, new Set());
206
+ const release = () => this.releaseWorker(workerId);
207
+ if (typeof worker.addEventListener === "function") {
208
+ worker.addEventListener("close", release);
209
+ } else if (worker.onclose !== undefined) {
210
+ worker.onclose = release;
211
+ }
212
+ return workerId;
213
+ }
214
+
215
+ /**
216
+ * Automatically register workers as they are created.
217
+ *
218
+ * In Bun, `process.on("worker", ...)` fires whenever a Worker is created in
219
+ * this process, passing the Worker instance. This lets the coordinator track
220
+ * every worker without the caller manually calling `registerWorker`. The
221
+ * worker's `onclose` handler is set to release all of its references on
222
+ * close/crash.
223
+ *
224
+ * Returns a function that removes the listener.
225
+ */
226
+ autoRegister(): () => void {
227
+ const listener = (worker: { onclose?: (() => void) | null }) => {
228
+ this.registerWorker(worker);
229
+ };
230
+ process.on("worker", listener);
231
+ return () => {
232
+ process.off("worker", listener);
233
+ };
234
+ }
235
+
236
+ /** Release all references held by a worker (e.g. on close/crash). */
237
+ releaseWorker(workerId: number): void {
238
+ const refs = this.workerRefs.get(workerId);
239
+ if (!refs) return;
240
+ for (const objectId of refs) {
241
+ this.releaseGlobal(objectId, workerId);
242
+ }
243
+ refs.clear();
244
+ }
245
+
246
+ /** Record that a worker acquired a reference to an objectId. */
247
+ acquire(objectId: number, workerId: number): void {
248
+ this.buffer.acquireGlobal(objectId);
249
+ this.workerRefs.get(workerId)?.add(objectId);
250
+ let ws = this.objectWorkers.get(objectId);
251
+ if (!ws) {
252
+ ws = new Set();
253
+ this.objectWorkers.set(objectId, ws);
254
+ }
255
+ ws.add(workerId);
256
+ }
257
+
258
+ /** Record that a worker released a reference to an objectId. */
259
+ release(objectId: number, workerId: number): void {
260
+ this.releaseGlobal(objectId, workerId);
261
+ }
262
+
263
+ private releaseGlobal(objectId: number, workerId: number): void {
264
+ this.buffer.releaseGlobal(objectId);
265
+ this.workerRefs.get(workerId)?.delete(objectId);
266
+ const ws = this.objectWorkers.get(objectId);
267
+ ws?.delete(workerId);
268
+ if (ws && ws.size === 0) {
269
+ this.objectWorkers.delete(objectId);
270
+ }
271
+ }
272
+
273
+ /** Read the global refcount for an objectId. */
274
+ refCount(objectId: number): number {
275
+ return this.buffer.refCount(objectId);
276
+ }
277
+
278
+ /** Drain pending acquire/release events from the ring buffer. */
279
+ drainEvents(): void {
280
+ let ev: RefEvent | null;
281
+ while ((ev = this.buffer.popEvent()) !== null) {
282
+ if (ev.op === 1) this.acquire(ev.objectId, ev.workerId);
283
+ else this.release(ev.objectId, ev.workerId);
284
+ }
285
+ }
286
+
287
+ /** Whether any table has less than `defragThreshold` free space. */
288
+ needsDefrag(): boolean {
289
+ const max = this.mem.dataTable.buffer.maxByteLength;
290
+ const free = (b: { buffer: SharedArrayBuffer; byteLength: number }) =>
291
+ (b.buffer.byteLength - b.byteLength) / max;
292
+ return (
293
+ free(this.mem.dataTable) < this.defragThreshold ||
294
+ free(this.mem.arrayTable) < this.defragThreshold ||
295
+ free(this.mem.stringTable) < this.defragThreshold
296
+ );
297
+ }
298
+
299
+ /**
300
+ * Start periodic maintenance on the main thread.
301
+ *
302
+ * Every `intervalMs` milliseconds this:
303
+ * 1. drains pending acquire/release events from the ring buffer, and
304
+ * 2. runs a defragmentation pass if any table has less than
305
+ * `defragThreshold` free space.
306
+ *
307
+ * This is what actually reclaims SAB memory: without it, refcount events
308
+ * accumulate unprocessed and dead space is never compacted. Returns a
309
+ * function that stops the timer.
310
+ */
311
+ startAutoMaintenance(intervalMs = 100): () => void {
312
+ if (this.maintenanceTimer !== null) return () => {};
313
+ this.maintenanceTimer = setInterval(() => {
314
+ this.drainEvents();
315
+ if (this.needsDefrag()) {
316
+ this.mem.withLock(() => this.mem.defragment());
317
+ }
318
+ }, intervalMs);
319
+ return () => this.stopAutoMaintenance();
320
+ }
321
+
322
+ /** Stop the periodic maintenance timer, if running. */
323
+ stopAutoMaintenance(): void {
324
+ if (this.maintenanceTimer !== null) {
325
+ clearInterval(this.maintenanceTimer);
326
+ this.maintenanceTimer = null;
327
+ }
328
+ }
329
+ }
330
+
331
+ /**
332
+ * Worker-side tracker: uses a FinalizationRegistry so a reference is released
333
+ * when the returned JS object/array is garbage-collected.
334
+ */
335
+ export class RefCountTracker {
336
+ private readonly buffer: RefCountBuffer;
337
+ private readonly registry: FinalizationRegistry<number>;
338
+ private readonly workerId: number;
339
+
340
+ constructor(buffer: RefCountBuffer, workerId: number) {
341
+ this.buffer = buffer;
342
+ this.workerId = workerId;
343
+ this.registry = new FinalizationRegistry((objectId) => {
344
+ this.buffer.pushEvent(this.workerId, 0, objectId);
345
+ });
346
+ }
347
+
348
+ /** Register a returned JS object/array so its ref is released on GC. */
349
+ track(value: object, objectId: number): void {
350
+ this.buffer.pushEvent(this.workerId, 1, objectId);
351
+ this.registry.register(value, objectId);
352
+ }
353
+
354
+ /** Periodically called by the worker to signal it is alive. */
355
+ heartbeat(): void {
356
+ this.buffer.heartbeat(this.workerId);
357
+ }
358
+ }