bun-shared-memory 0.1.8 → 0.2.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/shared.ts CHANGED
@@ -2,6 +2,9 @@ import {
2
2
  SharedMemory,
3
3
  TYPE_ARRAY_REF,
4
4
  TYPE_BIGINT_STR,
5
+ TYPE_INLINE_STRING,
6
+ TYPE_STRING_REF,
7
+ TYPE_STRING_VALUE_REF,
5
8
  arrayRefSlot,
6
9
  bigintSlot,
7
10
  bigintStrSlot,
@@ -9,6 +12,7 @@ import {
9
12
  decodeSlot,
10
13
  numberSlot,
11
14
  stringRefSlot,
15
+ stringValueRefSlot,
12
16
  type ArrayRef,
13
17
  type ObjectLayout,
14
18
  type StringRef,
@@ -32,6 +36,26 @@ const MUTATING_METHODS = new Set([
32
36
 
33
37
  type Primitive = number | string | boolean | bigint;
34
38
 
39
+ /**
40
+ * Run a write operation. Writes are lock-free by default: they increment the
41
+ * in-flight write counter, and only fall back to the shared mutex if a
42
+ * defragmentation pass is running (rare). This keeps the hot write path free
43
+ * of the mutex's re-entrancy tracking while still preventing a write from
44
+ * racing with a defragmentation pass.
45
+ */
46
+ function writeLock<T>(mem: SharedMemory, fn: () => T): T {
47
+ mem.beginWrite();
48
+ if (mem.isDefragmenting()) {
49
+ mem.endWrite();
50
+ return mem.withLock(fn);
51
+ }
52
+ try {
53
+ return fn();
54
+ } finally {
55
+ mem.endWrite();
56
+ }
57
+ }
58
+
35
59
  /** Inclusive bounds of a signed 64-bit integer (stored as a fast int64 slot). */
36
60
  const MIN_INT64 = -(2n ** 63n);
37
61
  const MAX_INT64 = 2n ** 63n - 1n;
@@ -41,6 +65,7 @@ function encodeValue(
41
65
  mem: SharedMemory,
42
66
  value: unknown,
43
67
  arrayCapacity?: number,
68
+ oldSlot?: ValueSlot,
44
69
  ): ValueSlot {
45
70
  switch (typeof value) {
46
71
  case "number":
@@ -55,7 +80,12 @@ function encodeValue(
55
80
  }
56
81
  return bigintStrSlot(mem.storeString(value.toString()));
57
82
  case "string":
58
- return stringRefSlot(mem.storeString(value));
83
+ // Reuse the existing string block in place when the new string fits.
84
+ const oldRef =
85
+ oldSlot?.type === TYPE_STRING_VALUE_REF
86
+ ? { offset: oldSlot.a, length: oldSlot.b }
87
+ : undefined;
88
+ return stringValueRefSlot(mem.storeStringValue(value, oldRef));
59
89
  default:
60
90
  if (Array.isArray(value)) {
61
91
  for (const v of value) {
@@ -77,8 +107,12 @@ function encodeValue(
77
107
  /** Decode a ValueSlot into a JS value, reading strings/arrays from tables. */
78
108
  function decodeValue(mem: SharedMemory, slot: ValueSlot): unknown {
79
109
  switch (slot.type) {
80
- case 3: // string-ref
110
+ case TYPE_STRING_REF: // property-name/string-table ref
81
111
  return mem.readString({ offset: slot.a, length: slot.b });
112
+ case TYPE_STRING_VALUE_REF: // string value in the ArrayTable
113
+ return mem.readStringValue({ offset: slot.a, length: slot.b });
114
+ case TYPE_INLINE_STRING: // short string stored inline in the slot
115
+ return mem.readInlineString(slot.a, slot.b, slot.c);
82
116
  case TYPE_BIGINT_STR: // bigint stored as decimal string
83
117
  return BigInt(mem.readString({ offset: slot.a, length: slot.b }));
84
118
  case 4: // array-ref
@@ -90,6 +124,18 @@ function decodeValue(mem: SharedMemory, slot: ValueSlot): unknown {
90
124
  }
91
125
  }
92
126
 
127
+ /**
128
+ * Free the blocks referenced by a value slot (an array block or a string),
129
+ * so they can be reused by the slab allocator. Called before a slot is
130
+ * overwritten with a new value. No-op for primitive slots.
131
+ *
132
+ * No-op: string-value/array/object blocks are no longer freed on the hot
133
+ * path (they are reclaimed by defragmentation instead).
134
+ */
135
+ function freeSlotRefs(_mem: SharedMemory, _slot: ValueSlot): void {
136
+ // Intentionally a no-op; dead blocks are reclaimed by defrag.
137
+ }
138
+
93
139
  /**
94
140
  * A reference to a value stored in shared memory.
95
141
  *
@@ -137,6 +183,18 @@ const readCache = new Map<
137
183
  */
138
184
  const indexCache = new Map<string, number>();
139
185
 
186
+ /**
187
+ * Per-worker cache of a key's current DataTable offset, valid only for the
188
+ * layout version it was resolved under. The offset changes on
189
+ * GC/defragmentation, so it is re-validated against the global layout version
190
+ * on each access (a single Atomics.load). This avoids the Index-Table read on
191
+ * every write, which is the dominant cost of the write hot path.
192
+ */
193
+ const offsetCache = new Map<
194
+ string,
195
+ { offset: number; layoutVersion: number }
196
+ >();
197
+
140
198
  /** Resolve a key's stable index, caching it after the first lookup. */
141
199
  function getIndex(mem: SharedMemory, key: string): number {
142
200
  const cached = indexCache.get(key);
@@ -166,6 +224,65 @@ function getOffset(mem: SharedMemory, key: string): number | null {
166
224
  return mem.lookupByIndex(index);
167
225
  }
168
226
 
227
+ /**
228
+ * The per-key cache fields on a SharedRef proxy target that the write hot
229
+ * paths use to skip Map lookups and DataTable reads. `__offset`/`__type` are
230
+ * valid only while `__layoutVersion` matches the global layout version;
231
+ * `__arrayRef` additionally requires `__arrayRefLayoutVersion` to match (it is
232
+ * bumped on defragmentation and on array growth via `moveArray`).
233
+ */
234
+ interface WriteTarget {
235
+ __offset?: number;
236
+ __layoutVersion: number;
237
+ __type?: number;
238
+ __arrayRef?: { offset: number; count: number; capacity: number } | undefined;
239
+ __arrayRefLayoutVersion: number;
240
+ __index?: number | undefined;
241
+ }
242
+
243
+ /**
244
+ * Resolve a key's current DataTable offset, caching it across writes. The
245
+ * cached offset is only reused when the global layout version is unchanged
246
+ * (i.e. no defragmentation has moved the data since it was cached); otherwise
247
+ * it is re-resolved from the Index Table.
248
+ */
249
+ function getCachedOffset(
250
+ mem: SharedMemory,
251
+ key: string,
252
+ target?: WriteTarget,
253
+ ): number | null {
254
+ if (target !== undefined && target.__offset !== undefined) {
255
+ if (target.__layoutVersion === mem.getLayoutVersion()) {
256
+ return target.__offset;
257
+ }
258
+ target.__offset = undefined;
259
+ target.__type = undefined;
260
+ target.__arrayRef = undefined;
261
+ }
262
+ const cached = offsetCache.get(key);
263
+ if (cached !== undefined) {
264
+ if (cached.layoutVersion === mem.getLayoutVersion()) {
265
+ if (target !== undefined) {
266
+ target.__offset = cached.offset;
267
+ target.__layoutVersion = cached.layoutVersion;
268
+ }
269
+ return cached.offset;
270
+ }
271
+ offsetCache.delete(key);
272
+ }
273
+ const offset = getOffset(mem, key);
274
+ if (offset !== null) {
275
+ offsetCache.set(key, { offset, layoutVersion: mem.getLayoutVersion() });
276
+ if (target !== undefined) {
277
+ target.__offset = offset;
278
+ target.__layoutVersion = mem.getLayoutVersion();
279
+ target.__type = undefined;
280
+ target.__arrayRef = undefined;
281
+ }
282
+ }
283
+ return offset;
284
+ }
285
+
169
286
  /** Whether a decoded value is a reference-countable object/array. */
170
287
  function isTrackable(value: unknown): boolean {
171
288
  return value !== null && typeof value === "object";
@@ -338,6 +455,44 @@ function readProp(
338
455
  return propValue;
339
456
  }
340
457
 
458
+ /**
459
+ * Read a single object property for the per-property accessor cache, decoding
460
+ * only the requested slot (not the whole object) and caching the decoded value
461
+ * in `fieldCache` under the observed version. Array properties are cached as
462
+ * an array proxy so subsequent reads hit the fast path.
463
+ *
464
+ * This is the miss path for the object accessor getters; the hit path returns
465
+ * `fieldCache.value` directly.
466
+ */
467
+ function readObjectProp(
468
+ mem: SharedMemory,
469
+ key: string,
470
+ prop: string,
471
+ fieldCache: { version: number; value: unknown },
472
+ version: number,
473
+ arrayCapacity?: number,
474
+ ): unknown {
475
+ const layout = getObjectLayout(mem, key);
476
+ if (layout === null) {
477
+ // Not an object (shouldn't happen for an object accessor); fall back.
478
+ return readProp(mem, key, prop, arrayCapacity);
479
+ }
480
+ const slotOffset = layout.properties.get(prop);
481
+ if (slotOffset === undefined) {
482
+ throw new TypeError(
483
+ `Value for key "${key}" has a fixed shape: properties may not be added or removed`,
484
+ );
485
+ }
486
+ const slot = mem.readValueSlot(slotOffset);
487
+ let value = decodeValue(mem, slot);
488
+ if (Array.isArray(value)) {
489
+ value = createArrayProxy(mem, key, prop, arrayCapacity);
490
+ }
491
+ fieldCache.version = version;
492
+ fieldCache.value = value;
493
+ return value;
494
+ }
495
+
341
496
  /**
342
497
  * Write a single property of a shared object value in place.
343
498
  *
@@ -350,8 +505,36 @@ function writeProp(
350
505
  prop: string | symbol,
351
506
  val: unknown,
352
507
  arrayCapacity?: number,
508
+ target?: WriteTarget,
353
509
  ): void {
354
- mem.withLock(() => {
510
+ // Inline writeLock to avoid a closure allocation on the hot path.
511
+ mem.beginWrite();
512
+ if (mem.isDefragmenting()) {
513
+ mem.endWrite();
514
+ mem.withLock(() =>
515
+ writePropInner(mem, key, prop, val, arrayCapacity, target),
516
+ );
517
+ return;
518
+ }
519
+ try {
520
+ writePropInner(mem, key, prop, val, arrayCapacity, target);
521
+ } finally {
522
+ mem.endWrite();
523
+ }
524
+ }
525
+
526
+ function writePropInner(
527
+ mem: SharedMemory,
528
+ key: string,
529
+ prop: string | symbol,
530
+ val: unknown,
531
+ arrayCapacity?: number,
532
+ target?: WriteTarget,
533
+ ): void {
534
+ // If the slot type is already cached on the target, we can skip the
535
+ // getObjectLayout Map lookup for top-level arrays (which are never objects).
536
+ const knownType = target?.__type;
537
+ if (knownType !== TYPE_ARRAY_REF) {
355
538
  // Object property: use the cached layout to write only that slot.
356
539
  const layout = getObjectLayout(mem, key);
357
540
  if (layout !== null) {
@@ -361,70 +544,136 @@ function writeProp(
361
544
  `Value for key "${key}" has a fixed shape: properties may not be added or removed`,
362
545
  );
363
546
  }
364
- mem.updateValueSlot(slotOffset, encodeValue(mem, val));
365
- bump(mem, key);
547
+ if (typeof val === "number") {
548
+ // Fast path: numbers need no old-slot read (freeSlotRefs is a no-op)
549
+ // and no in-place string reuse; write the float64 directly.
550
+ mem.updateNumberSlot(slotOffset, val);
551
+ bumpTarget(mem, key, target);
552
+ return;
553
+ }
554
+ if (typeof val === "string") {
555
+ // Fast path: short strings are written inline into the slot itself.
556
+ if (mem.updateInlineStringSlot(slotOffset, val)) {
557
+ bumpTarget(mem, key, target);
558
+ return;
559
+ }
560
+ }
561
+ const oldSlot = mem.readValueSlot(slotOffset);
562
+ freeSlotRefs(mem, oldSlot);
563
+ mem.updateValueSlot(
564
+ slotOffset,
565
+ encodeValue(mem, val, arrayCapacity, oldSlot),
566
+ );
567
+ bumpTarget(mem, key, target);
366
568
  return;
367
569
  }
570
+ }
368
571
 
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);
572
+ // Top-level array index write: update only that array slot.
573
+ const offset = getCachedOffset(mem, key, target);
574
+ if (offset === null) {
575
+ throw new Error(`No shared value found for key "${key}"`);
576
+ }
577
+ const slotType =
578
+ target !== undefined && target.__type !== undefined
579
+ ? target.__type
580
+ : mem.dataTable.getInt32(offset);
581
+ if (target !== undefined && target.__type === undefined) {
582
+ target.__type = slotType;
583
+ }
584
+ if (
585
+ slotType === TYPE_ARRAY_REF &&
586
+ typeof prop === "string" &&
587
+ /^\d+$/.test(prop)
588
+ ) {
589
+ const index = Number(prop);
590
+ // Use the cached ArrayRef when it is still valid (same layout version,
591
+ // i.e. no defragmentation or array growth has moved it). Otherwise
592
+ // re-read the 3 header Int32s and refresh the cache.
593
+ let ref = target?.__arrayRef;
375
594
  if (
376
- slot.type === TYPE_ARRAY_REF &&
377
- typeof prop === "string" &&
378
- /^\d+$/.test(prop)
595
+ ref === undefined ||
596
+ target!.__arrayRefLayoutVersion !== mem.getLayoutVersion()
379
597
  ) {
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
- );
598
+ ref = {
599
+ offset: mem.dataTable.getInt32(offset + 4),
600
+ count: mem.dataTable.getInt32(offset + 8),
601
+ capacity: mem.dataTable.getInt32(offset + 12),
602
+ };
603
+ if (target !== undefined) {
604
+ target.__arrayRef = ref;
605
+ target.__arrayRefLayoutVersion = mem.getLayoutVersion();
386
606
  }
387
- mem.updateArraySlot(ref, index, encodeValue(mem, val));
388
- bump(mem, key);
607
+ }
608
+ if (index >= ref.count) {
609
+ throw new RangeError(
610
+ `Index ${index} out of bounds for array of length ${ref.count}`,
611
+ );
612
+ }
613
+ if (typeof val === "number") {
614
+ // Fast path: numbers need no old-slot read (freeSlotRefs is a no-op).
615
+ mem.updateArrayNumberSlot(ref, index, val);
616
+ bumpTarget(mem, key, target);
389
617
  return;
390
618
  }
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);
619
+ if (typeof val === "boolean") {
620
+ // Fast path: booleans need no old-slot read (freeSlotRefs is a no-op).
621
+ mem.updateArrayBooleanSlot(ref, index, val);
622
+ bumpTarget(mem, key, target);
623
+ return;
624
+ }
625
+ if (typeof val === "string") {
626
+ // Fast path: short strings are written inline into the array slot.
627
+ if (mem.updateArrayInlineStringSlot(ref, index, val)) {
628
+ bumpTarget(mem, key, target);
409
629
  return;
410
630
  }
411
631
  }
632
+ freeSlotRefs(mem, mem.readArraySlot(ref, index));
633
+ mem.updateArraySlot(ref, index, encodeValue(mem, val));
634
+ bumpTarget(mem, key, target);
635
+ return;
636
+ }
412
637
 
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
- });
638
+ // Top-level array length write: shrink/grow the array in place.
639
+ if (slotType === TYPE_ARRAY_REF && prop === "length") {
640
+ const newLen = Number(val);
641
+ if (!Number.isInteger(newLen) || newLen < 0) {
642
+ throw new RangeError("Invalid array length");
643
+ }
644
+ const ref = {
645
+ offset: mem.dataTable.getInt32(offset + 4),
646
+ count: mem.dataTable.getInt32(offset + 8),
647
+ capacity: mem.dataTable.getInt32(offset + 12),
648
+ };
649
+ if (newLen <= ref.capacity) {
650
+ mem.updateValueSlot(
651
+ offset,
652
+ arrayRefSlot({
653
+ offset: ref.offset,
654
+ count: newLen,
655
+ capacity: ref.capacity,
656
+ }),
657
+ );
658
+ bumpTarget(mem, key, target);
659
+ return;
660
+ }
661
+ }
662
+
663
+ // Fallback: full read-modify-write.
664
+ mutateValue<unknown, boolean>(
665
+ mem,
666
+ key,
667
+ (value) => {
668
+ if (Array.isArray(value)) {
669
+ (value as unknown as Record<string | symbol, unknown>)[prop] = val;
670
+ } else {
671
+ (value as Record<string | symbol, unknown>)[prop] = val;
672
+ }
673
+ return true;
674
+ },
675
+ arrayCapacity,
676
+ );
428
677
  }
429
678
 
430
679
  /** Create a SharedRef proxy backed by a SharedMemory instance. */
@@ -444,6 +693,26 @@ function createSharedRef<T>(
444
693
  // the version check in the get handler.
445
694
  __cache: undefined as
446
695
  { version: number; value: unknown; index: number } | undefined,
696
+ // Cached DataTable offset for this key, valid only while the global
697
+ // layout version is unchanged (i.e. no defragmentation has moved it).
698
+ // Mirrors the module-level offsetCache to avoid a Map lookup on the
699
+ // write hot path.
700
+ __offset: undefined as number | undefined,
701
+ __layoutVersion: -1,
702
+ // Cached slot type for this key, so the write hot path can skip the
703
+ // DataTable type read when the value's type is known to be stable.
704
+ __type: undefined as number | undefined,
705
+ // Cached ArrayRef for a top-level array value, so the write hot path can
706
+ // skip re-reading the 3 header Int32s on every index write. Valid only
707
+ // while the global layout version is unchanged (no defragmentation) and
708
+ // while the array has not grown past the cached count (checked by the
709
+ // caller via `index < ref.count`).
710
+ __arrayRef: undefined as
711
+ { offset: number; count: number; capacity: number } | undefined,
712
+ __arrayRefLayoutVersion: -1,
713
+ // Cached version-cell index for this key, so the write hot path can bump
714
+ // the version cell without a Map lookup. Stable for the key's lifetime.
715
+ __index: undefined as number | undefined,
447
716
  read: () => {
448
717
  const value = copyValue(readValue(mem, key, target)) as T;
449
718
  if (tracker && isTrackable(value)) {
@@ -454,7 +723,7 @@ function createSharedRef<T>(
454
723
  },
455
724
  write: (value: T) => {
456
725
  target.__cache = undefined;
457
- writeValue(mem, key, value, arrayCapacity);
726
+ writeValue(mem, key, value, arrayCapacity, target);
458
727
  },
459
728
  update: (fn: (current: T) => T) => {
460
729
  target.__cache = undefined;
@@ -521,33 +790,62 @@ function createSharedRef<T>(
521
790
  },
522
791
  });
523
792
  const ref = Object.create(proto) as Record<string | symbol, unknown>;
793
+ // Resolve the object's stable version-cell index lazily (the value may not
794
+ // be written to shared memory until after this accessor is built) and cache
795
+ // it so the hot getter can load the version cell directly without a Map
796
+ // lookup or an index→cell computation on every read. The cell index is
797
+ // stable for the key's lifetime (the Index Table is never reordered).
798
+ let cellIndex: number | null = null;
524
799
  for (const name of propNames) {
800
+ // Per-property cache slot bound to this getter closure (mirrors
801
+ // cofusion's per-field `desc.cachedVer`/`desc.cachedVal`). On a cache
802
+ // hit the getter returns the already-decoded value directly, avoiding
803
+ // the `readCache` Map hash lookup and the whole-object `v[name]`
804
+ // extraction on the hot path.
805
+ const fieldCache: { version: number; value: unknown } = {
806
+ version: -1,
807
+ value: undefined,
808
+ };
809
+ // Stable array proxy for this property, cached across writes. A write
810
+ // bumps the version and invalidates `fieldCache`, which would otherwise
811
+ // recreate the array proxy on every access. The proxy reads fresh data
812
+ // on each access, so it stays correct without being recreated.
813
+ let arrayProxy: unknown[] | undefined;
525
814
  Object.defineProperty(ref, name, {
526
815
  enumerable: true,
527
816
  configurable: false,
528
817
  get() {
529
818
  // Lock-free fast path inlined so the JIT can optimize the hot
530
819
  // 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;
820
+ if (cellIndex === null) {
821
+ cellIndex = mem.getVersionCellIndex(getIndex(mem, key));
822
+ }
823
+ const version = Atomics.load(mem.versionView, cellIndex);
824
+ if (fieldCache.version === version) {
825
+ return fieldCache.value;
826
+ }
827
+ // If this property is an array, return the stable cached proxy
828
+ // (which reads fresh data on each access) instead of recreating it
829
+ // on every write.
830
+ if (arrayProxy !== undefined) {
831
+ return arrayProxy;
545
832
  }
546
- return readProp(mem, key, name, arrayCapacity);
833
+ const value = readObjectProp(
834
+ mem,
835
+ key,
836
+ name,
837
+ fieldCache,
838
+ version,
839
+ arrayCapacity,
840
+ );
841
+ if (Array.isArray(value)) {
842
+ arrayProxy = value;
843
+ }
844
+ return value;
547
845
  },
548
846
  set(v: unknown) {
549
847
  target.__cache = undefined;
550
- writeProp(mem, key, name, v, arrayCapacity);
848
+ writeProp(mem, key, name, v, arrayCapacity, target);
551
849
  },
552
850
  });
553
851
  }
@@ -581,7 +879,7 @@ function createSharedRef<T>(
581
879
  },
582
880
  set(t, prop, val) {
583
881
  t.__cache = undefined;
584
- writeProp(mem, key, prop, val, arrayCapacity);
882
+ writeProp(mem, key, prop, val, arrayCapacity, t);
585
883
  return true;
586
884
  },
587
885
  deleteProperty() {
@@ -661,61 +959,152 @@ function writeValue(
661
959
  key: string,
662
960
  value: unknown,
663
961
  arrayCapacity?: number,
962
+ target?: WriteTarget,
664
963
  ): void {
665
- mem.withLock(() => {
666
- const existing = getOffset(mem, key);
667
- if (existing !== null) {
668
- const type = mem.dataTable.getInt32(existing);
669
- if (type === 5) {
670
- // Update existing object's property slots in place.
671
- const properties = mem.readObject(existing);
672
- const valueObj = value as Record<string, unknown>;
673
- for (let i = 0; i < properties.length; i++) {
674
- const [name] = properties[i]!;
675
- if (!(name in valueObj)) {
676
- throw new TypeError(
677
- `Value for key "${key}" has a fixed shape: properties may not be added or removed`,
678
- );
679
- }
680
- mem.updateObjectSlot(
681
- existing,
682
- i,
683
- encodeValue(mem, valueObj[name], arrayCapacity),
964
+ // Inline writeLock to avoid a closure allocation on the hot path.
965
+ mem.beginWrite();
966
+ if (mem.isDefragmenting()) {
967
+ mem.endWrite();
968
+ mem.withLock(() => writeValueInner(mem, key, value, arrayCapacity, target));
969
+ return;
970
+ }
971
+ try {
972
+ writeValueInner(mem, key, value, arrayCapacity, target);
973
+ } finally {
974
+ mem.endWrite();
975
+ }
976
+ }
977
+
978
+ function writeValueInner(
979
+ mem: SharedMemory,
980
+ key: string,
981
+ value: unknown,
982
+ arrayCapacity?: number,
983
+ target?: WriteTarget,
984
+ ): void {
985
+ const existing = getCachedOffset(mem, key, target);
986
+ if (existing !== null) {
987
+ // Skip the DataTable type read when the slot type is known to be stable
988
+ // (cached on the target). Only re-read when the type is unknown.
989
+ let type = target?.__type;
990
+ if (type === undefined) {
991
+ type = mem.dataTable.getInt32(existing);
992
+ if (target !== undefined) target.__type = type;
993
+ }
994
+ if (type === 5) {
995
+ // Update existing object's property slots in place.
996
+ const properties = mem.readObject(existing);
997
+ const valueObj = value as Record<string, unknown>;
998
+ for (let i = 0; i < properties.length; i++) {
999
+ const [name] = properties[i]!;
1000
+ if (!(name in valueObj)) {
1001
+ throw new TypeError(
1002
+ `Value for key "${key}" has a fixed shape: properties may not be added or removed`,
684
1003
  );
685
1004
  }
686
- // Check for extra properties.
687
- const existingNames = new Set(properties.map(([n]) => n));
688
- for (const name of Object.keys(valueObj)) {
689
- if (!existingNames.has(name)) {
690
- throw new TypeError(
691
- `Value for key "${key}" has a fixed shape: properties may not be added or removed`,
692
- );
693
- }
1005
+ // Free the old referenced blocks (array/string) before overwriting.
1006
+ const oldPropSlot = properties[i]![1];
1007
+ freeSlotRefs(mem, oldPropSlot);
1008
+ mem.updateObjectSlot(
1009
+ existing,
1010
+ i,
1011
+ encodeValue(mem, valueObj[name], arrayCapacity, oldPropSlot),
1012
+ );
1013
+ }
1014
+ // Check for extra properties.
1015
+ const existingNames = new Set(properties.map(([n]) => n));
1016
+ for (const name of Object.keys(valueObj)) {
1017
+ if (!existingNames.has(name)) {
1018
+ throw new TypeError(
1019
+ `Value for key "${key}" has a fixed shape: properties may not be added or removed`,
1020
+ );
694
1021
  }
695
- bump(mem, key);
696
- return;
697
1022
  }
698
- // Update a single value slot in place.
699
- mem.updateValueSlot(existing, encodeValue(mem, value, arrayCapacity));
700
- bump(mem, key);
1023
+ bumpTarget(mem, key, target);
701
1024
  return;
702
1025
  }
703
-
704
- if (value !== null && typeof value === "object" && !Array.isArray(value)) {
705
- // Create a new object.
706
- const valueObj = value as Record<string, unknown>;
707
- const props: Array<[string, ValueSlot]> = Object.entries(valueObj).map(
708
- ([name, v]) => [name, encodeValue(mem, v, arrayCapacity)],
1026
+ // Update a single value slot in place.
1027
+ if (typeof value === "number") {
1028
+ // Fast path: numbers need no old-slot read (freeSlotRefs is a no-op)
1029
+ // and no in-place string reuse; write the float64 directly.
1030
+ mem.updateNumberSlot(existing, value);
1031
+ bumpTarget(mem, key, target);
1032
+ return;
1033
+ }
1034
+ const isPrimitive = typeof value === "boolean" || typeof value === "bigint";
1035
+ if (isPrimitive) {
1036
+ // Fast path: primitives need no old-slot read (freeSlotRefs is a
1037
+ // no-op) and no in-place string reuse.
1038
+ if (typeof value === "boolean") {
1039
+ mem.updateBooleanSlot(existing, value);
1040
+ } else {
1041
+ mem.updateValueSlot(existing, encodeValue(mem, value, arrayCapacity));
1042
+ }
1043
+ bumpTarget(mem, key, target);
1044
+ return;
1045
+ }
1046
+ if (typeof value === "string") {
1047
+ // Fast path: short strings are written inline into the slot itself,
1048
+ // avoiding a heap allocation and the string-table write. Longer strings
1049
+ // read the old string ref directly (no ValueSlot object allocation) so
1050
+ // storeStringValue can reuse the block in place.
1051
+ if (mem.updateInlineStringSlot(existing, value)) {
1052
+ bumpTarget(mem, key, target);
1053
+ return;
1054
+ }
1055
+ const oldType = mem.dataTable.getInt32(existing);
1056
+ const oldA = mem.dataTable.getInt32(existing + 4);
1057
+ const oldB = mem.dataTable.getInt32(existing + 8);
1058
+ const ref = mem.storeStringValue(
1059
+ value,
1060
+ oldType === TYPE_STRING_VALUE_REF
1061
+ ? { offset: oldA, length: oldB }
1062
+ : undefined,
709
1063
  );
710
- const offset = mem.createObject(props);
711
- mem.insert(key, offset);
1064
+ mem.updateValueSlot(existing, stringValueRefSlot(ref));
1065
+ bumpTarget(mem, key, target);
712
1066
  return;
713
1067
  }
1068
+ const oldSlot = mem.readValueSlot(existing);
1069
+ freeSlotRefs(mem, oldSlot);
1070
+ mem.updateValueSlot(
1071
+ existing,
1072
+ encodeValue(mem, value, arrayCapacity, oldSlot),
1073
+ );
1074
+ bumpTarget(mem, key, target);
1075
+ return;
1076
+ }
714
1077
 
715
- // Create a single value slot (primitive or array).
716
- const offset = mem.storeValueSlot(encodeValue(mem, value, arrayCapacity));
1078
+ if (value !== null && typeof value === "object" && !Array.isArray(value)) {
1079
+ // Create a new object.
1080
+ const valueObj = value as Record<string, unknown>;
1081
+ const props: Array<[string, ValueSlot]> = Object.entries(valueObj).map(
1082
+ ([name, v]) => [name, encodeValue(mem, v, arrayCapacity)],
1083
+ );
1084
+ const offset = mem.createObject(props);
717
1085
  mem.insert(key, offset);
718
- });
1086
+ const lv = mem.getLayoutVersion();
1087
+ offsetCache.set(key, { offset, layoutVersion: lv });
1088
+ if (target !== undefined) {
1089
+ target.__offset = offset;
1090
+ target.__layoutVersion = lv;
1091
+ target.__type = undefined;
1092
+ target.__arrayRef = undefined;
1093
+ }
1094
+ return;
1095
+ }
1096
+
1097
+ // Create a single value slot (primitive or array).
1098
+ const offset = mem.storeValueSlot(encodeValue(mem, value, arrayCapacity));
1099
+ mem.insert(key, offset);
1100
+ const lv = mem.getLayoutVersion();
1101
+ offsetCache.set(key, { offset, layoutVersion: lv });
1102
+ if (target !== undefined) {
1103
+ target.__offset = offset;
1104
+ target.__layoutVersion = lv;
1105
+ target.__type = undefined;
1106
+ target.__arrayRef = undefined;
1107
+ }
719
1108
  }
720
1109
 
721
1110
  /** Bump the version cell for a key, invalidating read caches. */
@@ -723,6 +1112,25 @@ function bump(mem: SharedMemory, key: string): void {
723
1112
  mem.bumpVersion(getIndex(mem, key));
724
1113
  }
725
1114
 
1115
+ /**
1116
+ * Bump the version cell for a key using the target's cached index, avoiding
1117
+ * the `getIndex` Map lookup on the write hot path. Falls back to `bump` when
1118
+ * the index is not yet cached.
1119
+ */
1120
+ function bumpTarget(
1121
+ mem: SharedMemory,
1122
+ key: string,
1123
+ target?: WriteTarget,
1124
+ ): void {
1125
+ if (target !== undefined && target.__index !== undefined) {
1126
+ mem.bumpVersion(target.__index);
1127
+ return;
1128
+ }
1129
+ const index = getIndex(mem, key);
1130
+ if (target !== undefined) target.__index = index;
1131
+ mem.bumpVersion(index);
1132
+ }
1133
+
726
1134
  /** Atomically update the value for a key. */
727
1135
  function updateValue<T>(
728
1136
  mem: SharedMemory,
@@ -730,7 +1138,7 @@ function updateValue<T>(
730
1138
  fn: (current: T) => T,
731
1139
  arrayCapacity?: number,
732
1140
  ): T {
733
- return mem.withLock(() => {
1141
+ return writeLock(mem, () => {
734
1142
  const current = readValue(mem, key) as T;
735
1143
  const next = fn(current);
736
1144
  writeValue(mem, key, next, arrayCapacity);
@@ -749,7 +1157,7 @@ function mutateValue<T, R>(
749
1157
  fn: (current: T) => R,
750
1158
  arrayCapacity?: number,
751
1159
  ): R {
752
- return mem.withLock(() => {
1160
+ return writeLock(mem, () => {
753
1161
  const current = readValue(mem, key) as T;
754
1162
  const result = fn(current);
755
1163
  writeValue(mem, key, current, arrayCapacity);
@@ -794,7 +1202,7 @@ function mutateArrayRef(
794
1202
  method: "push" | "pop",
795
1203
  args: unknown[],
796
1204
  ): unknown {
797
- return mem.withLock(() => {
1205
+ return writeLock(mem, () => {
798
1206
  const slotOffset = findArrayRefSlot(mem, key, prop);
799
1207
  if (slotOffset === null) {
800
1208
  throw new TypeError("Value is not an array");
@@ -822,6 +1230,8 @@ function mutateArrayRef(
822
1230
  // Read the last element before decrementing.
823
1231
  const lastSlot = mem.readArraySlot(ref, ref.count - 1);
824
1232
  const result = decodeValue(mem, lastSlot);
1233
+ // Free the removed element's referenced blocks (array/string).
1234
+ freeSlotRefs(mem, lastSlot);
825
1235
  const next = {
826
1236
  offset: ref.offset,
827
1237
  count: ref.count - 1,
@@ -883,6 +1293,14 @@ function createArrayProxy(
883
1293
  prop: string,
884
1294
  arrayCapacity?: number,
885
1295
  ): unknown[] {
1296
+ // Cache the object's property slot offset and the array ref so index writes
1297
+ // skip the per-write getObjectLayout Map lookup, properties.get, and the
1298
+ // DataTable header reads. Invalidated when the layout version changes
1299
+ // (defragmentation or array growth via moveArray).
1300
+ let cachedSlotOffset: number | undefined;
1301
+ let cachedRef:
1302
+ { offset: number; count: number; capacity: number } | undefined;
1303
+ let cachedLayoutVersion = -1;
886
1304
  return new Proxy([] as unknown[], {
887
1305
  get(_target, arrProp) {
888
1306
  // push/pop on an array property: operate directly on the array ref
@@ -940,59 +1358,116 @@ function createArrayProxy(
940
1358
  set(_target, arrProp, val) {
941
1359
  // In-place update: write only the affected array slot instead of
942
1360
  // decoding and re-encoding the whole object.
943
- return mem.withLock(() => {
944
- const layout = getObjectLayout(mem, key);
945
- if (layout !== null) {
946
- const slotOffset = layout.properties.get(prop);
947
- if (slotOffset !== undefined) {
948
- const propSlot = mem.readValueSlot(slotOffset);
949
- if (
950
- propSlot.type === TYPE_ARRAY_REF &&
951
- typeof arrProp === "string" &&
952
- /^\d+$/.test(arrProp)
953
- ) {
954
- const index = Number(arrProp);
955
- const ref = {
956
- offset: propSlot.a,
957
- count: propSlot.b,
958
- capacity: propSlot.c,
959
- };
960
- if (index >= ref.count) {
961
- throw new RangeError(
962
- `Index ${index} out of bounds for array of length ${ref.count}`,
963
- );
964
- }
965
- mem.updateArraySlot(ref, index, encodeValue(mem, val));
1361
+ return writeLock(mem, () => {
1362
+ // Refresh the cached slot offset when the layout version changed
1363
+ // (defragmentation may have moved the object block).
1364
+ if (cachedLayoutVersion !== mem.getLayoutVersion()) {
1365
+ cachedSlotOffset = undefined;
1366
+ cachedRef = undefined;
1367
+ cachedLayoutVersion = mem.getLayoutVersion();
1368
+ }
1369
+ if (cachedSlotOffset === undefined) {
1370
+ const layout = getObjectLayout(mem, key);
1371
+ if (layout === null) {
1372
+ // Fallback: full read-modify-write.
1373
+ return mutateValue<Record<string, unknown>, boolean>(
1374
+ mem,
1375
+ key,
1376
+ (fresh) => {
1377
+ const arr = fresh[prop] as unknown[];
1378
+ (arr as unknown as Record<string | symbol, unknown>)[arrProp] =
1379
+ val;
1380
+ return true;
1381
+ },
1382
+ arrayCapacity,
1383
+ );
1384
+ }
1385
+ const so = layout.properties.get(prop);
1386
+ if (so === undefined) {
1387
+ throw new TypeError(
1388
+ `Value for key "${key}" has a fixed shape: properties may not be added or removed`,
1389
+ );
1390
+ }
1391
+ cachedSlotOffset = so;
1392
+ }
1393
+ const propSlot = mem.readValueSlot(cachedSlotOffset);
1394
+ if (
1395
+ propSlot.type === TYPE_ARRAY_REF &&
1396
+ typeof arrProp === "string" &&
1397
+ /^\d+$/.test(arrProp)
1398
+ ) {
1399
+ const index = Number(arrProp);
1400
+ // Use the cached ref when valid; re-read when the array may have
1401
+ // grown past the cached count (push) or the layout changed.
1402
+ let ref = cachedRef;
1403
+ if (
1404
+ ref === undefined ||
1405
+ index >= ref.count ||
1406
+ cachedLayoutVersion !== mem.getLayoutVersion()
1407
+ ) {
1408
+ ref = {
1409
+ offset: propSlot.a,
1410
+ count: propSlot.b,
1411
+ capacity: propSlot.c,
1412
+ };
1413
+ cachedRef = ref;
1414
+ }
1415
+ if (index >= ref.count) {
1416
+ throw new RangeError(
1417
+ `Index ${index} out of bounds for array of length ${ref.count}`,
1418
+ );
1419
+ }
1420
+ if (typeof val === "number") {
1421
+ // Fast path: numbers need no old-slot read (freeSlotRefs is a
1422
+ // no-op) and no in-place string reuse.
1423
+ mem.updateArrayNumberSlot(ref, index, val);
1424
+ bump(mem, key);
1425
+ return true;
1426
+ }
1427
+ if (typeof val === "boolean") {
1428
+ // Fast path: booleans need no old-slot read (freeSlotRefs is a
1429
+ // no-op).
1430
+ mem.updateArrayBooleanSlot(ref, index, val);
1431
+ bump(mem, key);
1432
+ return true;
1433
+ }
1434
+ if (typeof val === "string") {
1435
+ // Fast path: short strings are written inline into the slot.
1436
+ if (mem.updateArrayInlineStringSlot(ref, index, val)) {
966
1437
  bump(mem, key);
967
1438
  return true;
968
1439
  }
969
- // Array property length write: shrink/grow in place without
970
- // re-encoding the whole object (which would leave a dead slot).
971
- if (propSlot.type === TYPE_ARRAY_REF && arrProp === "length") {
972
- const newLen = Number(val);
973
- if (!Number.isInteger(newLen) || newLen < 0) {
974
- throw new RangeError("Invalid array length");
975
- }
976
- const ref = {
977
- offset: propSlot.a,
978
- count: propSlot.b,
979
- capacity: propSlot.c,
980
- };
981
- if (newLen <= ref.capacity) {
982
- mem.updateValueSlot(
983
- slotOffset,
984
- arrayRefSlot({
985
- offset: ref.offset,
986
- count: newLen,
987
- capacity: ref.capacity,
988
- }),
989
- );
990
- bump(mem, key);
991
- return true;
992
- }
993
- // Growing beyond capacity falls through to the full read-modify-write.
994
- }
995
1440
  }
1441
+ freeSlotRefs(mem, mem.readArraySlot(ref, index));
1442
+ mem.updateArraySlot(ref, index, encodeValue(mem, val));
1443
+ bump(mem, key);
1444
+ return true;
1445
+ }
1446
+ // Array property length write: shrink/grow in place without
1447
+ // re-encoding the whole object (which would leave a dead slot).
1448
+ if (propSlot.type === TYPE_ARRAY_REF && arrProp === "length") {
1449
+ const newLen = Number(val);
1450
+ if (!Number.isInteger(newLen) || newLen < 0) {
1451
+ throw new RangeError("Invalid array length");
1452
+ }
1453
+ const ref = {
1454
+ offset: propSlot.a,
1455
+ count: propSlot.b,
1456
+ capacity: propSlot.c,
1457
+ };
1458
+ if (newLen <= ref.capacity) {
1459
+ mem.updateValueSlot(
1460
+ cachedSlotOffset,
1461
+ arrayRefSlot({
1462
+ offset: ref.offset,
1463
+ count: newLen,
1464
+ capacity: ref.capacity,
1465
+ }),
1466
+ );
1467
+ bump(mem, key);
1468
+ return true;
1469
+ }
1470
+ // Growing beyond capacity falls through to the full read-modify-write.
996
1471
  }
997
1472
  // Fallback: full read-modify-write.
998
1473
  return mutateValue<Record<string, unknown>, boolean>(