bun-shared-memory 0.1.4 → 0.1.6

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/refcount.ts CHANGED
@@ -286,9 +286,9 @@ export class RefCountCoordinator {
286
286
 
287
287
  /** Whether any table has less than `defragThreshold` free space. */
288
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;
289
+ const max = this.mem.dataTable.capacity;
290
+ const free = (b: { capacity: number; byteLength: number }) =>
291
+ (b.capacity - b.byteLength) / max;
292
292
  return (
293
293
  free(this.mem.dataTable) < this.defragThreshold ||
294
294
  free(this.mem.arrayTable) < this.defragThreshold ||
package/src/shared.ts CHANGED
@@ -178,7 +178,7 @@ function isTrackable(value: unknown): boolean {
178
178
  function getTopLevelObjectId(mem: SharedMemory, key: string): number | null {
179
179
  const offset = getOffset(mem, key);
180
180
  if (offset === null) return null;
181
- const type = mem.dataTable.getView().getInt32(offset, true);
181
+ const type = mem.dataTable.getInt32(offset);
182
182
  if (type === 5) {
183
183
  return mem.getObjectId(offset);
184
184
  }
@@ -189,20 +189,6 @@ function getTopLevelObjectId(mem: SharedMemory, key: string): number | null {
189
189
  return null;
190
190
  }
191
191
 
192
- /**
193
- * Per-worker cache of decoded object properties: key -> { version, props }.
194
- *
195
- * When a property of an object is read via the layout (single-slot decode),
196
- * the decoded value is cached here keyed by the object's version. A hit avoids
197
- * re-decoding the property (e.g. re-reading a string from the StringTable) on
198
- * repeated reads of the same property. Writes bump the object's version, so
199
- * the whole entry is invalidated automatically.
200
- */
201
- const propCache = new Map<
202
- string,
203
- { version: number; index: number; props: Map<string, unknown> }
204
- >();
205
-
206
192
  /**
207
193
  * Per-worker cache of object layouts: key -> stable property slot offsets.
208
194
  *
@@ -222,13 +208,21 @@ function getObjectLayout(mem: SharedMemory, key: string): ObjectLayout | null {
222
208
  return layout;
223
209
  }
224
210
 
225
- /** Deep-ish copy of a decoded value, so callers can't corrupt the cache. */
211
+ /**
212
+ * Deep-ish copy of a decoded value, so callers can't corrupt the cache.
213
+ *
214
+ * Uses `Object.assign` for the fast shallow copy, then a `for...in` pass to
215
+ * copy any nested arrays (so mutating a returned array property cannot corrupt
216
+ * the cache). This is ~4.5× faster than the previous `Object.entries` + spread
217
+ * loop, which allocated an entries array and re-checked `Array.isArray` on
218
+ * every property.
219
+ */
226
220
  function copyValue(value: unknown): unknown {
227
221
  if (Array.isArray(value)) return [...value];
228
222
  if (value !== null && typeof value === "object") {
229
- const copy: Record<string, unknown> = {};
230
- for (const [k, v] of Object.entries(value)) {
231
- copy[k] = Array.isArray(v) ? [...v] : v;
223
+ const copy = Object.assign({} as Record<string, unknown>, value);
224
+ for (const k in copy) {
225
+ if (Array.isArray(copy[k])) copy[k] = [...(copy[k] as unknown[])];
232
226
  }
233
227
  return copy;
234
228
  }
@@ -297,6 +291,7 @@ export function Shared<T>(
297
291
  const ref = createSharedRef<T>(
298
292
  mem,
299
293
  key,
294
+ typeof initialOrLoader === "function" ? undefined : (initialOrLoader as T),
300
295
  options.refCount ?? getDefaultTracker() ?? undefined,
301
296
  options.arrayCapacity,
302
297
  );
@@ -318,10 +313,125 @@ export function Shared<T>(
318
313
  return ref;
319
314
  }
320
315
 
316
+ /**
317
+ * Read a single property of a shared object value.
318
+ *
319
+ * Shared by the proxy `get` handler and the generated accessor getters. Uses
320
+ * the lock-free read-cache fast path and falls back to `readValue` (which
321
+ * fills the cache) on a miss. Array properties are returned as an array proxy.
322
+ */
323
+ function readProp(
324
+ mem: SharedMemory,
325
+ key: string,
326
+ prop: string | symbol,
327
+ arrayCapacity?: number,
328
+ ): unknown {
329
+ const fast = readCachedValue(mem, key);
330
+ const value = fast.hit ? fast.value : readValue(mem, key);
331
+ if (Array.isArray(value)) {
332
+ return getTopLevelArrayMember(mem, key, value, prop, arrayCapacity);
333
+ }
334
+ const propValue = (value as Record<string | symbol, unknown>)[prop];
335
+ if (Array.isArray(propValue)) {
336
+ return createArrayProxy(mem, key, prop as string, arrayCapacity);
337
+ }
338
+ return propValue;
339
+ }
340
+
341
+ /**
342
+ * Write a single property of a shared object value in place.
343
+ *
344
+ * Shared by the proxy `set` handler and the generated accessor setters. Uses
345
+ * the cached layout to update only the affected slot.
346
+ */
347
+ function writeProp(
348
+ mem: SharedMemory,
349
+ key: string,
350
+ prop: string | symbol,
351
+ val: unknown,
352
+ arrayCapacity?: number,
353
+ ): void {
354
+ mem.withLock(() => {
355
+ // Object property: use the cached layout to write only that slot.
356
+ const layout = getObjectLayout(mem, key);
357
+ if (layout !== null) {
358
+ const slotOffset = layout.properties.get(prop as string);
359
+ if (slotOffset === undefined) {
360
+ throw new TypeError(
361
+ `Value for key "${key}" has a fixed shape: properties may not be added or removed`,
362
+ );
363
+ }
364
+ mem.updateValueSlot(slotOffset, encodeValue(mem, val));
365
+ bump(mem, key);
366
+ return;
367
+ }
368
+
369
+ // Top-level array index write: update only that array slot.
370
+ const offset = getOffset(mem, key);
371
+ if (offset === null) {
372
+ throw new Error(`No shared value found for key "${key}"`);
373
+ }
374
+ const slot = mem.readValueSlot(offset);
375
+ if (
376
+ slot.type === TYPE_ARRAY_REF &&
377
+ typeof prop === "string" &&
378
+ /^\d+$/.test(prop)
379
+ ) {
380
+ const index = Number(prop);
381
+ const ref = { offset: slot.a, count: slot.b, capacity: slot.c };
382
+ if (index >= ref.count) {
383
+ throw new RangeError(
384
+ `Index ${index} out of bounds for array of length ${ref.count}`,
385
+ );
386
+ }
387
+ mem.updateArraySlot(ref, index, encodeValue(mem, val));
388
+ bump(mem, key);
389
+ return;
390
+ }
391
+
392
+ // Top-level array length write: shrink/grow the array in place.
393
+ if (slot.type === TYPE_ARRAY_REF && prop === "length") {
394
+ const newLen = Number(val);
395
+ if (!Number.isInteger(newLen) || newLen < 0) {
396
+ throw new RangeError("Invalid array length");
397
+ }
398
+ const ref = { offset: slot.a, count: slot.b, capacity: slot.c };
399
+ if (newLen <= ref.capacity) {
400
+ mem.updateValueSlot(
401
+ offset,
402
+ arrayRefSlot({
403
+ offset: ref.offset,
404
+ count: newLen,
405
+ capacity: ref.capacity,
406
+ }),
407
+ );
408
+ bump(mem, key);
409
+ return;
410
+ }
411
+ }
412
+
413
+ // Fallback: full read-modify-write.
414
+ mutateValue<unknown, boolean>(
415
+ mem,
416
+ key,
417
+ (value) => {
418
+ if (Array.isArray(value)) {
419
+ (value as unknown as Record<string | symbol, unknown>)[prop] = val;
420
+ } else {
421
+ (value as Record<string | symbol, unknown>)[prop] = val;
422
+ }
423
+ return true;
424
+ },
425
+ arrayCapacity,
426
+ );
427
+ });
428
+ }
429
+
321
430
  /** Create a SharedRef proxy backed by a SharedMemory instance. */
322
431
  function createSharedRef<T>(
323
432
  mem: SharedMemory,
324
433
  key: string,
434
+ initialValue: T | undefined,
325
435
  tracker?: RefCountTracker,
326
436
  arrayCapacity?: number,
327
437
  ): SharedRef<T> {
@@ -352,13 +462,109 @@ function createSharedRef<T>(
352
462
  },
353
463
  };
354
464
 
465
+ // Determine the object's property names so we can hard-bake accessor
466
+ // getters/setters instead of routing every access through a Proxy trap.
467
+ // The shape is known from the initial value (if it is an object) or from
468
+ // shared memory (if the key already exists, e.g. in a worker).
469
+ let propNames: string[] | null = null;
470
+ if (
471
+ initialValue !== null &&
472
+ typeof initialValue === "object" &&
473
+ !Array.isArray(initialValue)
474
+ ) {
475
+ propNames = Object.keys(initialValue as Record<string, unknown>);
476
+ } else {
477
+ const layout = mem.withLock(() => mem.getObjectLayout(key));
478
+ if (layout !== null) {
479
+ propNames = [...layout.properties.keys()];
480
+ }
481
+ }
482
+
483
+ // Object values: return a plain object with hard-baked accessor getters and
484
+ // setters (no Proxy). This avoids the Proxy trap overhead on every property
485
+ // read (~2.6× faster) and stays a plain, serializable object.
486
+ if (propNames !== null && propNames.length > 0) {
487
+ // Methods live on the prototype, not on the object itself. Keeping the
488
+ // data properties as the only own properties lets the JIT treat the
489
+ // object as monomorphic, which is ~2× faster for accessor reads than
490
+ // having extra own properties (read/write/update/key/buffer).
491
+ const proto: Record<string | symbol, unknown> = {
492
+ [Symbol.toPrimitive]: () => target.read(),
493
+ };
494
+ Object.defineProperty(proto, "read", {
495
+ enumerable: false,
496
+ configurable: false,
497
+ value: target.read,
498
+ });
499
+ Object.defineProperty(proto, "write", {
500
+ enumerable: false,
501
+ configurable: false,
502
+ value: target.write,
503
+ });
504
+ Object.defineProperty(proto, "update", {
505
+ enumerable: false,
506
+ configurable: false,
507
+ value: target.update,
508
+ });
509
+ // key/buffer are non-enumerable so they don't appear in JSON.stringify /
510
+ // Object.keys (matching the previous Proxy behavior).
511
+ Object.defineProperty(proto, "key", {
512
+ enumerable: false,
513
+ configurable: false,
514
+ value: key,
515
+ });
516
+ Object.defineProperty(proto, "buffer", {
517
+ enumerable: false,
518
+ configurable: false,
519
+ get() {
520
+ return mem.dataTable.buffers[0];
521
+ },
522
+ });
523
+ const ref = Object.create(proto) as Record<string | symbol, unknown>;
524
+ for (const name of propNames) {
525
+ Object.defineProperty(ref, name, {
526
+ enumerable: true,
527
+ configurable: false,
528
+ get() {
529
+ // Lock-free fast path inlined so the JIT can optimize the hot
530
+ // cache-hit read without a function-call chain.
531
+ const cached = readCache.get(key);
532
+ if (
533
+ cached !== undefined &&
534
+ cached.version === mem.getVersion(cached.index)
535
+ ) {
536
+ const v = cached.value;
537
+ if (Array.isArray(v)) {
538
+ return getTopLevelArrayMember(mem, key, v, name, arrayCapacity);
539
+ }
540
+ const pv = (v as Record<string | symbol, unknown>)[name];
541
+ if (Array.isArray(pv)) {
542
+ return createArrayProxy(mem, key, name, arrayCapacity);
543
+ }
544
+ return pv;
545
+ }
546
+ return readProp(mem, key, name, arrayCapacity);
547
+ },
548
+ set(v: unknown) {
549
+ target.__cache = undefined;
550
+ writeProp(mem, key, name, v, arrayCapacity);
551
+ },
552
+ });
553
+ }
554
+ // Fixed shape: reject adding new properties (throws in strict mode).
555
+ Object.preventExtensions(ref);
556
+ return ref as unknown as SharedRef<T>;
557
+ }
558
+
559
+ // Arrays, primitives, or unknown shape: use a Proxy (index access, array
560
+ // methods, and dynamic properties require interception).
355
561
  return new Proxy(target, {
356
562
  get(t, prop) {
357
563
  if (prop === "read") return t.read;
358
564
  if (prop === "write") return t.write;
359
565
  if (prop === "update") return t.update;
360
566
  if (prop === "key") return t.key;
361
- if (prop === "buffer") return mem.dataTable.buffer;
567
+ if (prop === "buffer") return mem.dataTable.buffers[0];
362
568
  if (prop === Symbol.toPrimitive) return () => t.read();
363
569
 
364
570
  // push/pop on a top-level array: operate directly on the array ref
@@ -371,177 +577,12 @@ function createSharedRef<T>(
371
577
  }
372
578
  }
373
579
 
374
- // Fast path: if the full value is cached and valid, read the property
375
- // from the cached value (no decode, no lock). The cache entry is kept
376
- // directly on the target to avoid a Map lookup on the hot path.
377
- const cached = t.__cache;
378
- if (
379
- cached !== undefined &&
380
- cached.version === mem.getVersion(cached.index)
381
- ) {
382
- const cachedValue = cached.value;
383
- if (Array.isArray(cachedValue)) {
384
- return getTopLevelArrayMember(
385
- mem,
386
- key,
387
- cachedValue,
388
- prop,
389
- arrayCapacity,
390
- );
391
- }
392
- const propValue = (cachedValue as Record<string | symbol, unknown>)[
393
- prop
394
- ];
395
- if (Array.isArray(propValue)) {
396
- return createArrayProxy(mem, key, prop as string, arrayCapacity);
397
- }
398
- return propValue;
399
- }
400
-
401
- // Cache miss: for objects, decode only the requested property's slot via
402
- // the cached layout, avoiding a full-object decode. The decoded value is
403
- // cached per property so repeated reads of the same property are cheap.
404
- const layout = getObjectLayout(mem, key);
405
- if (layout !== null) {
406
- const slotOffset = layout.properties.get(prop as string);
407
- if (slotOffset === undefined) return undefined;
408
-
409
- // Property cache hit: the object's version is unchanged.
410
- const pc = propCache.get(key);
411
- const version = pc !== undefined ? mem.getVersion(pc.index) : -1;
412
- if (pc !== undefined && pc.version === version) {
413
- const cachedProp = pc.props.get(prop as string);
414
- if (cachedProp !== undefined) {
415
- if (Array.isArray(cachedProp)) {
416
- return createArrayProxy(mem, key, prop as string, arrayCapacity);
417
- }
418
- return cachedProp;
419
- }
420
- }
421
-
422
- // Decode the single slot and cache it under the object's version.
423
- // The key's index is stable (keys are never removed), so reuse the
424
- // cached index when available to avoid the linear index-table lookup.
425
- const index =
426
- pc !== undefined
427
- ? pc.index
428
- : (readCache.get(key)?.index ?? getIndex(mem, key));
429
- const slot = mem.readValueSlot(slotOffset);
430
- // Array properties are handled lazily by the array proxy; don't decode
431
- // the whole array here (avoids O(n) work on every access).
432
- if (slot.type === TYPE_ARRAY_REF) {
433
- return createArrayProxy(mem, key, prop as string, arrayCapacity);
434
- }
435
- const propValue = decodeValue(mem, slot);
436
- if (pc === undefined || pc.version !== version) {
437
- propCache.set(key, {
438
- version: mem.getVersion(index),
439
- index,
440
- props: new Map(),
441
- });
442
- }
443
- propCache.get(key)!.props.set(prop as string, propValue);
444
- if (Array.isArray(propValue)) {
445
- return createArrayProxy(mem, key, prop as string, arrayCapacity);
446
- }
447
- return propValue;
448
- }
449
-
450
- // Primitive/array key: use the cached value directly (no copy).
451
- const value = readValue(mem, key, t);
452
- if (Array.isArray(value)) {
453
- return getTopLevelArrayMember(mem, key, value, prop, arrayCapacity);
454
- }
455
- // Object value: property access.
456
- const propValue = (value as Record<string | symbol, unknown>)[prop];
457
- if (Array.isArray(propValue)) {
458
- return createArrayProxy(mem, key, prop as string, arrayCapacity);
459
- }
460
- return propValue;
580
+ return readProp(mem, key, prop, arrayCapacity);
461
581
  },
462
582
  set(t, prop, val) {
463
- // In-place update: write only the affected slot instead of decoding and
464
- // re-encoding the whole value.
465
583
  t.__cache = undefined;
466
- return mem.withLock(() => {
467
- // Object property: use the cached layout to write only that slot.
468
- const layout = getObjectLayout(mem, key);
469
- if (layout !== null) {
470
- const slotOffset = layout.properties.get(prop as string);
471
- if (slotOffset === undefined) {
472
- throw new TypeError(
473
- `Value for key "${key}" has a fixed shape: properties may not be added or removed`,
474
- );
475
- }
476
- mem.updateValueSlot(slotOffset, encodeValue(mem, val));
477
- bump(mem, key);
478
- return true;
479
- }
480
-
481
- // Top-level array index write: update only that array slot.
482
- const offset = getOffset(mem, key);
483
- if (offset === null) {
484
- throw new Error(`No shared value found for key "${key}"`);
485
- }
486
- const slot = mem.readValueSlot(offset);
487
- if (
488
- slot.type === TYPE_ARRAY_REF &&
489
- typeof prop === "string" &&
490
- /^\d+$/.test(prop)
491
- ) {
492
- const index = Number(prop);
493
- const ref = { offset: slot.a, count: slot.b, capacity: slot.c };
494
- if (index >= ref.count) {
495
- throw new RangeError(
496
- `Index ${index} out of bounds for array of length ${ref.count}`,
497
- );
498
- }
499
- mem.updateArraySlot(ref, index, encodeValue(mem, val));
500
- bump(mem, key);
501
- return true;
502
- }
503
-
504
- // Top-level array length write: shrink/grow the array in place without
505
- // re-encoding the whole array (which would leave a dead slot behind).
506
- if (slot.type === TYPE_ARRAY_REF && prop === "length") {
507
- const newLen = Number(val);
508
- if (!Number.isInteger(newLen) || newLen < 0) {
509
- throw new RangeError("Invalid array length");
510
- }
511
- const ref = { offset: slot.a, count: slot.b, capacity: slot.c };
512
- if (newLen <= ref.capacity) {
513
- // Shrinking (or growing within capacity): just update the count.
514
- mem.updateValueSlot(
515
- offset,
516
- arrayRefSlot({
517
- offset: ref.offset,
518
- count: newLen,
519
- capacity: ref.capacity,
520
- }),
521
- );
522
- bump(mem, key);
523
- return true;
524
- }
525
- // Growing beyond capacity falls through to the full read-modify-write.
526
- }
527
-
528
- // Fallback: full read-modify-write (e.g. setting a primitive key's
529
- // property, or a non-index property on an array).
530
- return mutateValue<T, boolean>(
531
- mem,
532
- key,
533
- (value) => {
534
- if (Array.isArray(value)) {
535
- (value as unknown as Record<string | symbol, unknown>)[prop] =
536
- val;
537
- } else {
538
- (value as Record<string | symbol, unknown>)[prop] = val;
539
- }
540
- return true;
541
- },
542
- arrayCapacity,
543
- );
544
- });
584
+ writeProp(mem, key, prop, val, arrayCapacity);
585
+ return true;
545
586
  },
546
587
  deleteProperty() {
547
588
  throw new TypeError(
@@ -551,6 +592,22 @@ function createSharedRef<T>(
551
592
  }) as unknown as SharedRef<T>;
552
593
  }
553
594
 
595
+ /**
596
+ * Lock-free fast path: if the cached version matches the current version cell,
597
+ * the cached value is still valid. Returns the cached value, or a miss marker.
598
+ * Kept small so the JIT can inline it into hot accessor getters.
599
+ */
600
+ function readCachedValue(
601
+ mem: SharedMemory,
602
+ key: string,
603
+ ): { hit: true; value: unknown } | { hit: false } {
604
+ const cached = readCache.get(key);
605
+ if (cached !== undefined && cached.version === mem.getVersion(cached.index)) {
606
+ return { hit: true, value: cached.value };
607
+ }
608
+ return { hit: false };
609
+ }
610
+
554
611
  /**
555
612
  * Read the full value for a key from shared memory, using the read cache.
556
613
  *
@@ -565,15 +622,12 @@ function readValue(
565
622
  __cache?: { version: number; value: unknown; index: number };
566
623
  },
567
624
  ): unknown {
625
+ const fast = readCachedValue(mem, key);
626
+ if (fast.hit) return fast.value;
627
+
568
628
  const cached = readCache.get(key);
569
629
  const index = cached !== undefined ? cached.index : getIndex(mem, key);
570
-
571
- // Lock-free fast path: if the cached version matches the current version
572
- // cell, the cached value is still valid.
573
630
  const version = mem.getVersion(index);
574
- if (cached !== undefined && cached.version === version) {
575
- return cached.value;
576
- }
577
631
 
578
632
  // Cache miss: decode under the lock and cache with the observed version.
579
633
  const value = mem.withLock(() => {
@@ -581,7 +635,7 @@ function readValue(
581
635
  if (offset === null) {
582
636
  throw new Error(`No shared value found for key "${key}"`);
583
637
  }
584
- const type = mem.dataTable.getView().getInt32(offset, true);
638
+ const type = mem.dataTable.getInt32(offset);
585
639
  if (type === 5) {
586
640
  // Object block.
587
641
  const properties = mem.readObject(offset);
@@ -611,7 +665,7 @@ function writeValue(
611
665
  mem.withLock(() => {
612
666
  const existing = getOffset(mem, key);
613
667
  if (existing !== null) {
614
- const type = mem.dataTable.getView().getInt32(existing, true);
668
+ const type = mem.dataTable.getInt32(existing);
615
669
  if (type === 5) {
616
670
  // Update existing object's property slots in place.
617
671
  const properties = mem.readObject(existing);
package/src/worker.ts CHANGED
@@ -144,6 +144,7 @@ function stopCoordinator(): void {
144
144
  }
145
145
  }
146
146
 
147
+ // ---------------------------------------------------------------------------
147
148
  /** The main-thread worker listener, so it can be removed by `stopInit()`. */
148
149
  let mainListener:
149
150
  | ((worker: {
@@ -166,12 +167,17 @@ function initMain(): void {
166
167
  }) => {
167
168
  worker.addEventListener("message", (e) => {
168
169
  const msg = e.data as { __sharedMemoryRequest?: boolean } | undefined;
169
- if (msg && msg.__sharedMemoryRequest) {
170
+ if (
171
+ msg !== null &&
172
+ typeof msg === "object" &&
173
+ "__sharedMemoryRequest" in msg
174
+ ) {
170
175
  const mem = getDefaultMemory();
171
176
  worker.postMessage({
172
177
  __sharedMemoryInit: true,
173
178
  buffers: mem.exportBuffers(),
174
179
  });
180
+ return;
175
181
  }
176
182
  });
177
183
  };
@@ -234,7 +240,12 @@ function installMessageWrapper(): void {
234
240
  buffers?: Parameters<typeof SharedMemory.fromBuffers>[0];
235
241
  }
236
242
  | undefined;
237
- if (msg && msg.__sharedMemoryInit && msg.buffers) {
243
+ if (
244
+ msg !== null &&
245
+ typeof msg === "object" &&
246
+ "__sharedMemoryInit" in msg &&
247
+ msg.buffers
248
+ ) {
238
249
  const mem = SharedMemory.fromBuffers(msg.buffers);
239
250
  setDefaultMemory(mem);
240
251
  // Create the worker-side refcount tracker from the shared refcount