bun-shared-memory 0.1.8 → 0.2.1

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";
@@ -326,8 +443,14 @@ function readProp(
326
443
  prop: string | symbol,
327
444
  arrayCapacity?: number,
328
445
  ): unknown {
329
- const fast = readCachedValue(mem, key);
330
- const value = fast.hit ? fast.value : readValue(mem, key);
446
+ // Inline the read-cache fast path (instead of calling readCachedValue, which
447
+ // returns a polymorphic {hit,value} union and allocates an object on every
448
+ // cache hit). Keeping this monomorphic lets the JIT optimize the hot read.
449
+ const cached = readCache.get(key);
450
+ const value =
451
+ cached !== undefined && cached.version === mem.getVersion(cached.index)
452
+ ? cached.value
453
+ : readValue(mem, key);
331
454
  if (Array.isArray(value)) {
332
455
  return getTopLevelArrayMember(mem, key, value, prop, arrayCapacity);
333
456
  }
@@ -338,6 +461,94 @@ function readProp(
338
461
  return propValue;
339
462
  }
340
463
 
464
+ /**
465
+ * Read a single object property for the per-property accessor cache, decoding
466
+ * only the requested slot (not the whole object) and caching the decoded value
467
+ * in `fieldCache` under the observed version. Array properties are cached as
468
+ * an array proxy so subsequent reads hit the fast path.
469
+ *
470
+ * This is the miss path for the object accessor getters; the hit path returns
471
+ * `fieldCache.value` directly.
472
+ */
473
+ function readObjectProp(
474
+ mem: SharedMemory,
475
+ key: string,
476
+ prop: string,
477
+ fieldCache: { version: number; value: unknown },
478
+ version: number,
479
+ arrayCapacity?: number,
480
+ ): unknown {
481
+ const layout = getObjectLayout(mem, key);
482
+ if (layout === null) {
483
+ // Not an object (shouldn't happen for an object accessor); fall back.
484
+ return readProp(mem, key, prop, arrayCapacity);
485
+ }
486
+ const slotOffset = layout.properties.get(prop);
487
+ if (slotOffset === undefined) {
488
+ throw new TypeError(
489
+ `Value for key "${key}" has a fixed shape: properties may not be added or removed`,
490
+ );
491
+ }
492
+ const slot = mem.readValueSlot(slotOffset);
493
+ let value = decodeValue(mem, slot);
494
+ if (Array.isArray(value)) {
495
+ value = createArrayProxy(mem, key, prop, arrayCapacity);
496
+ }
497
+ fieldCache.version = version;
498
+ fieldCache.value = value;
499
+ return value;
500
+ }
501
+
502
+ /**
503
+ * Write a single object-property slot whose offset is already resolved
504
+ * (cached by the caller, e.g. the accessor setter). Avoids the
505
+ * `getObjectLayout` Map lookup + `properties.get` per write on the hot path.
506
+ */
507
+ function writeObjectSlot(
508
+ mem: SharedMemory,
509
+ key: string,
510
+ slotOffset: number,
511
+ val: unknown,
512
+ arrayCapacity?: number,
513
+ target?: WriteTarget,
514
+ ): void {
515
+ if (typeof val === "number") {
516
+ // Fast path: numbers need no old-slot read (freeSlotRefs is a no-op)
517
+ // and no in-place string reuse; write the float64 directly.
518
+ mem.updateNumberSlot(slotOffset, val);
519
+ bumpTarget(mem, key, target);
520
+ return;
521
+ }
522
+ if (typeof val === "boolean") {
523
+ // Fast path: booleans need no old-slot read (freeSlotRefs is a no-op)
524
+ // and no in-place string reuse; write the 4 Int32s directly.
525
+ mem.updateBooleanSlot(slotOffset, val);
526
+ bumpTarget(mem, key, target);
527
+ return;
528
+ }
529
+ if (typeof val === "bigint" && val >= MIN_INT64 && val <= MAX_INT64) {
530
+ // Fast path: int64 bigints are written directly, avoiding the ValueSlot
531
+ // object allocation of the generic path.
532
+ mem.updateBigIntSlot(slotOffset, val);
533
+ bumpTarget(mem, key, target);
534
+ return;
535
+ }
536
+ if (typeof val === "string") {
537
+ // Fast path: short strings are written inline into the slot itself.
538
+ if (mem.updateInlineStringSlot(slotOffset, val)) {
539
+ bumpTarget(mem, key, target);
540
+ return;
541
+ }
542
+ }
543
+ const oldSlot = mem.readValueSlot(slotOffset);
544
+ freeSlotRefs(mem, oldSlot);
545
+ mem.updateValueSlot(
546
+ slotOffset,
547
+ encodeValue(mem, val, arrayCapacity, oldSlot),
548
+ );
549
+ bumpTarget(mem, key, target);
550
+ }
551
+
341
552
  /**
342
553
  * Write a single property of a shared object value in place.
343
554
  *
@@ -350,8 +561,36 @@ function writeProp(
350
561
  prop: string | symbol,
351
562
  val: unknown,
352
563
  arrayCapacity?: number,
564
+ target?: WriteTarget,
353
565
  ): void {
354
- mem.withLock(() => {
566
+ // Inline writeLock to avoid a closure allocation on the hot path.
567
+ mem.beginWrite();
568
+ if (mem.isDefragmenting()) {
569
+ mem.endWrite();
570
+ mem.withLock(() =>
571
+ writePropInner(mem, key, prop, val, arrayCapacity, target),
572
+ );
573
+ return;
574
+ }
575
+ try {
576
+ writePropInner(mem, key, prop, val, arrayCapacity, target);
577
+ } finally {
578
+ mem.endWrite();
579
+ }
580
+ }
581
+
582
+ function writePropInner(
583
+ mem: SharedMemory,
584
+ key: string,
585
+ prop: string | symbol,
586
+ val: unknown,
587
+ arrayCapacity?: number,
588
+ target?: WriteTarget,
589
+ ): void {
590
+ // If the slot type is already cached on the target, we can skip the
591
+ // getObjectLayout Map lookup for top-level arrays (which are never objects).
592
+ const knownType = target?.__type;
593
+ if (knownType !== TYPE_ARRAY_REF) {
355
594
  // Object property: use the cached layout to write only that slot.
356
595
  const layout = getObjectLayout(mem, key);
357
596
  if (layout !== null) {
@@ -361,70 +600,123 @@ function writeProp(
361
600
  `Value for key "${key}" has a fixed shape: properties may not be added or removed`,
362
601
  );
363
602
  }
364
- mem.updateValueSlot(slotOffset, encodeValue(mem, val));
365
- bump(mem, key);
603
+ writeObjectSlot(mem, key, slotOffset, val, arrayCapacity, target);
366
604
  return;
367
605
  }
606
+ }
368
607
 
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);
608
+ // Top-level array index write: update only that array slot.
609
+ const offset = getCachedOffset(mem, key, target);
610
+ if (offset === null) {
611
+ throw new Error(`No shared value found for key "${key}"`);
612
+ }
613
+ const slotType =
614
+ target !== undefined && target.__type !== undefined
615
+ ? target.__type
616
+ : mem.dataTable.getInt32(offset);
617
+ if (target !== undefined && target.__type === undefined) {
618
+ target.__type = slotType;
619
+ }
620
+ if (
621
+ slotType === TYPE_ARRAY_REF &&
622
+ typeof prop === "string" &&
623
+ /^\d+$/.test(prop)
624
+ ) {
625
+ const index = Number(prop);
626
+ // Use the cached ArrayRef when it is still valid (same layout version,
627
+ // i.e. no defragmentation or array growth has moved it). Otherwise
628
+ // re-read the 3 header Int32s and refresh the cache.
629
+ let ref = target?.__arrayRef;
375
630
  if (
376
- slot.type === TYPE_ARRAY_REF &&
377
- typeof prop === "string" &&
378
- /^\d+$/.test(prop)
631
+ ref === undefined ||
632
+ target!.__arrayRefLayoutVersion !== mem.getLayoutVersion()
379
633
  ) {
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
- );
634
+ ref = {
635
+ offset: mem.dataTable.getInt32(offset + 4),
636
+ count: mem.dataTable.getInt32(offset + 8),
637
+ capacity: mem.dataTable.getInt32(offset + 12),
638
+ };
639
+ if (target !== undefined) {
640
+ target.__arrayRef = ref;
641
+ target.__arrayRefLayoutVersion = mem.getLayoutVersion();
386
642
  }
387
- mem.updateArraySlot(ref, index, encodeValue(mem, val));
388
- bump(mem, key);
643
+ }
644
+ if (index >= ref.count) {
645
+ throw new RangeError(
646
+ `Index ${index} out of bounds for array of length ${ref.count}`,
647
+ );
648
+ }
649
+ if (typeof val === "number") {
650
+ // Fast path: numbers need no old-slot read (freeSlotRefs is a no-op).
651
+ mem.updateArrayNumberSlot(ref, index, val);
652
+ bumpTarget(mem, key, target);
389
653
  return;
390
654
  }
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);
655
+ if (typeof val === "boolean") {
656
+ // Fast path: booleans need no old-slot read (freeSlotRefs is a no-op).
657
+ mem.updateArrayBooleanSlot(ref, index, val);
658
+ bumpTarget(mem, key, target);
659
+ return;
660
+ }
661
+ if (typeof val === "bigint" && val >= MIN_INT64 && val <= MAX_INT64) {
662
+ // Fast path: int64 bigints are written directly, avoiding the
663
+ // ValueSlot object allocation of the generic path.
664
+ mem.updateArrayBigIntSlot(ref, index, val);
665
+ bumpTarget(mem, key, target);
666
+ return;
667
+ }
668
+ if (typeof val === "string") {
669
+ // Fast path: short strings are written inline into the array slot.
670
+ if (mem.updateArrayInlineStringSlot(ref, index, val)) {
671
+ bumpTarget(mem, key, target);
409
672
  return;
410
673
  }
411
674
  }
675
+ freeSlotRefs(mem, mem.readArraySlot(ref, index));
676
+ mem.updateArraySlot(ref, index, encodeValue(mem, val));
677
+ bumpTarget(mem, key, target);
678
+ return;
679
+ }
412
680
 
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
- });
681
+ // Top-level array length write: shrink/grow the array in place.
682
+ if (slotType === TYPE_ARRAY_REF && prop === "length") {
683
+ const newLen = Number(val);
684
+ if (!Number.isInteger(newLen) || newLen < 0) {
685
+ throw new RangeError("Invalid array length");
686
+ }
687
+ const ref = {
688
+ offset: mem.dataTable.getInt32(offset + 4),
689
+ count: mem.dataTable.getInt32(offset + 8),
690
+ capacity: mem.dataTable.getInt32(offset + 12),
691
+ };
692
+ if (newLen <= ref.capacity) {
693
+ mem.updateValueSlot(
694
+ offset,
695
+ arrayRefSlot({
696
+ offset: ref.offset,
697
+ count: newLen,
698
+ capacity: ref.capacity,
699
+ }),
700
+ );
701
+ bumpTarget(mem, key, target);
702
+ return;
703
+ }
704
+ }
705
+
706
+ // Fallback: full read-modify-write.
707
+ mutateValue<unknown, boolean>(
708
+ mem,
709
+ key,
710
+ (value) => {
711
+ if (Array.isArray(value)) {
712
+ (value as unknown as Record<string | symbol, unknown>)[prop] = val;
713
+ } else {
714
+ (value as Record<string | symbol, unknown>)[prop] = val;
715
+ }
716
+ return true;
717
+ },
718
+ arrayCapacity,
719
+ );
428
720
  }
429
721
 
430
722
  /** Create a SharedRef proxy backed by a SharedMemory instance. */
@@ -444,6 +736,26 @@ function createSharedRef<T>(
444
736
  // the version check in the get handler.
445
737
  __cache: undefined as
446
738
  { version: number; value: unknown; index: number } | undefined,
739
+ // Cached DataTable offset for this key, valid only while the global
740
+ // layout version is unchanged (i.e. no defragmentation has moved it).
741
+ // Mirrors the module-level offsetCache to avoid a Map lookup on the
742
+ // write hot path.
743
+ __offset: undefined as number | undefined,
744
+ __layoutVersion: -1,
745
+ // Cached slot type for this key, so the write hot path can skip the
746
+ // DataTable type read when the value's type is known to be stable.
747
+ __type: undefined as number | undefined,
748
+ // Cached ArrayRef for a top-level array value, so the write hot path can
749
+ // skip re-reading the 3 header Int32s on every index write. Valid only
750
+ // while the global layout version is unchanged (no defragmentation) and
751
+ // while the array has not grown past the cached count (checked by the
752
+ // caller via `index < ref.count`).
753
+ __arrayRef: undefined as
754
+ { offset: number; count: number; capacity: number } | undefined,
755
+ __arrayRefLayoutVersion: -1,
756
+ // Cached version-cell index for this key, so the write hot path can bump
757
+ // the version cell without a Map lookup. Stable for the key's lifetime.
758
+ __index: undefined as number | undefined,
447
759
  read: () => {
448
760
  const value = copyValue(readValue(mem, key, target)) as T;
449
761
  if (tracker && isTrackable(value)) {
@@ -454,7 +766,7 @@ function createSharedRef<T>(
454
766
  },
455
767
  write: (value: T) => {
456
768
  target.__cache = undefined;
457
- writeValue(mem, key, value, arrayCapacity);
769
+ writeValue(mem, key, value, arrayCapacity, target);
458
770
  },
459
771
  update: (fn: (current: T) => T) => {
460
772
  target.__cache = undefined;
@@ -521,33 +833,106 @@ function createSharedRef<T>(
521
833
  },
522
834
  });
523
835
  const ref = Object.create(proto) as Record<string | symbol, unknown>;
836
+ // Resolve the object's stable version-cell index lazily (the value may not
837
+ // be written to shared memory until after this accessor is built) and cache
838
+ // it so the hot getter can load the version cell directly without a Map
839
+ // lookup or an index→cell computation on every read. The cell index is
840
+ // stable for the key's lifetime (the Index Table is never reordered).
841
+ let cellIndex: number | null = null;
524
842
  for (const name of propNames) {
843
+ // Per-property cache slot bound to this getter closure (mirrors
844
+ // cofusion's per-field `desc.cachedVer`/`desc.cachedVal`). On a cache
845
+ // hit the getter returns the already-decoded value directly, avoiding
846
+ // the `readCache` Map hash lookup and the whole-object `v[name]`
847
+ // extraction on the hot path.
848
+ const fieldCache: { version: number; value: unknown } = {
849
+ version: -1,
850
+ value: undefined,
851
+ };
852
+ // Stable array proxy for this property, cached across writes. A write
853
+ // bumps the version and invalidates `fieldCache`, which would otherwise
854
+ // recreate the array proxy on every access. The proxy reads fresh data
855
+ // on each access, so it stays correct without being recreated.
856
+ let arrayProxy: unknown[] | undefined;
857
+ // Cached object slot offset for this property so writes skip the
858
+ // getObjectLayout Map lookup + properties.get on the hot path.
859
+ // Invalidated when the layout version changes (defragmentation moves
860
+ // the object block).
861
+ let slotOffset: number | undefined;
862
+ let slotLayoutVersion = -1;
525
863
  Object.defineProperty(ref, name, {
526
864
  enumerable: true,
527
865
  configurable: false,
528
866
  get() {
529
867
  // Lock-free fast path inlined so the JIT can optimize the hot
530
868
  // 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;
869
+ if (cellIndex === null) {
870
+ cellIndex = mem.getVersionCellIndex(getIndex(mem, key));
871
+ }
872
+ const version = Atomics.load(mem.versionView, cellIndex);
873
+ if (fieldCache.version === version) {
874
+ return fieldCache.value;
545
875
  }
546
- return readProp(mem, key, name, arrayCapacity);
876
+ // If this property is an array, return the stable cached proxy
877
+ // (which reads fresh data on each access) instead of recreating it
878
+ // on every write.
879
+ if (arrayProxy !== undefined) {
880
+ return arrayProxy;
881
+ }
882
+ const value = readObjectProp(
883
+ mem,
884
+ key,
885
+ name,
886
+ fieldCache,
887
+ version,
888
+ arrayCapacity,
889
+ );
890
+ if (Array.isArray(value)) {
891
+ arrayProxy = value;
892
+ }
893
+ return value;
547
894
  },
548
895
  set(v: unknown) {
549
896
  target.__cache = undefined;
550
- writeProp(mem, key, name, v, arrayCapacity);
897
+ mem.beginWrite();
898
+ if (mem.isDefragmenting()) {
899
+ // A defrag pass may move the object block: drop the cached slot
900
+ // offset so it is re-resolved, and run the write under the mutex.
901
+ mem.endWrite();
902
+ slotOffset = undefined;
903
+ slotLayoutVersion = -1;
904
+ mem.withLock(() =>
905
+ writePropInner(mem, key, name, v, arrayCapacity, target),
906
+ );
907
+ return;
908
+ }
909
+ try {
910
+ // Write directly to the cached slot offset when still valid,
911
+ // skipping the getObjectLayout Map lookup + properties.get.
912
+ if (slotLayoutVersion !== mem.getLayoutVersion()) {
913
+ slotOffset = undefined;
914
+ slotLayoutVersion = mem.getLayoutVersion();
915
+ }
916
+ if (slotOffset === undefined) {
917
+ const layout = getObjectLayout(mem, key);
918
+ if (layout === null) {
919
+ // Not an object (shouldn't happen for an object accessor).
920
+ throw new TypeError(
921
+ `Value for key "${key}" has a fixed shape: properties may not be added or removed`,
922
+ );
923
+ }
924
+ const so = layout.properties.get(name);
925
+ if (so === undefined) {
926
+ throw new TypeError(
927
+ `Value for key "${key}" has a fixed shape: properties may not be added or removed`,
928
+ );
929
+ }
930
+ slotOffset = so;
931
+ }
932
+ writeObjectSlot(mem, key, slotOffset, v, arrayCapacity, target);
933
+ } finally {
934
+ mem.endWrite();
935
+ }
551
936
  },
552
937
  });
553
938
  }
@@ -581,7 +966,7 @@ function createSharedRef<T>(
581
966
  },
582
967
  set(t, prop, val) {
583
968
  t.__cache = undefined;
584
- writeProp(mem, key, prop, val, arrayCapacity);
969
+ writeProp(mem, key, prop, val, arrayCapacity, t);
585
970
  return true;
586
971
  },
587
972
  deleteProperty() {
@@ -592,22 +977,6 @@ function createSharedRef<T>(
592
977
  }) as unknown as SharedRef<T>;
593
978
  }
594
979
 
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
-
611
980
  /**
612
981
  * Read the full value for a key from shared memory, using the read cache.
613
982
  *
@@ -622,10 +991,13 @@ function readValue(
622
991
  __cache?: { version: number; value: unknown; index: number };
623
992
  },
624
993
  ): unknown {
625
- const fast = readCachedValue(mem, key);
626
- if (fast.hit) return fast.value;
627
-
994
+ // Inline the read-cache fast path (see readProp) to avoid the polymorphic
995
+ // {hit,value} union returned by readCachedValue.
628
996
  const cached = readCache.get(key);
997
+ if (cached !== undefined && cached.version === mem.getVersion(cached.index)) {
998
+ return cached.value;
999
+ }
1000
+
629
1001
  const index = cached !== undefined ? cached.index : getIndex(mem, key);
630
1002
  const version = mem.getVersion(index);
631
1003
 
@@ -661,61 +1033,156 @@ function writeValue(
661
1033
  key: string,
662
1034
  value: unknown,
663
1035
  arrayCapacity?: number,
1036
+ target?: WriteTarget,
664
1037
  ): 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),
1038
+ // Inline writeLock to avoid a closure allocation on the hot path.
1039
+ mem.beginWrite();
1040
+ if (mem.isDefragmenting()) {
1041
+ mem.endWrite();
1042
+ mem.withLock(() => writeValueInner(mem, key, value, arrayCapacity, target));
1043
+ return;
1044
+ }
1045
+ try {
1046
+ writeValueInner(mem, key, value, arrayCapacity, target);
1047
+ } finally {
1048
+ mem.endWrite();
1049
+ }
1050
+ }
1051
+
1052
+ function writeValueInner(
1053
+ mem: SharedMemory,
1054
+ key: string,
1055
+ value: unknown,
1056
+ arrayCapacity?: number,
1057
+ target?: WriteTarget,
1058
+ ): void {
1059
+ const existing = getCachedOffset(mem, key, target);
1060
+ if (existing !== null) {
1061
+ // Skip the DataTable type read when the slot type is known to be stable
1062
+ // (cached on the target). Only re-read when the type is unknown.
1063
+ let type = target?.__type;
1064
+ if (type === undefined) {
1065
+ type = mem.dataTable.getInt32(existing);
1066
+ if (target !== undefined) target.__type = type;
1067
+ }
1068
+ if (type === 5) {
1069
+ // Update existing object's property slots in place.
1070
+ const properties = mem.readObject(existing);
1071
+ const valueObj = value as Record<string, unknown>;
1072
+ for (let i = 0; i < properties.length; i++) {
1073
+ const [name] = properties[i]!;
1074
+ if (!(name in valueObj)) {
1075
+ throw new TypeError(
1076
+ `Value for key "${key}" has a fixed shape: properties may not be added or removed`,
684
1077
  );
685
1078
  }
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
- }
1079
+ // Free the old referenced blocks (array/string) before overwriting.
1080
+ const oldPropSlot = properties[i]![1];
1081
+ freeSlotRefs(mem, oldPropSlot);
1082
+ mem.updateObjectSlot(
1083
+ existing,
1084
+ i,
1085
+ encodeValue(mem, valueObj[name], arrayCapacity, oldPropSlot),
1086
+ );
1087
+ }
1088
+ // Check for extra properties.
1089
+ const existingNames = new Set(properties.map(([n]) => n));
1090
+ for (const name of Object.keys(valueObj)) {
1091
+ if (!existingNames.has(name)) {
1092
+ throw new TypeError(
1093
+ `Value for key "${key}" has a fixed shape: properties may not be added or removed`,
1094
+ );
694
1095
  }
695
- bump(mem, key);
696
- return;
697
1096
  }
698
- // Update a single value slot in place.
699
- mem.updateValueSlot(existing, encodeValue(mem, value, arrayCapacity));
700
- bump(mem, key);
1097
+ bumpTarget(mem, key, target);
701
1098
  return;
702
1099
  }
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)],
1100
+ // Update a single value slot in place.
1101
+ if (typeof value === "number") {
1102
+ // Fast path: numbers need no old-slot read (freeSlotRefs is a no-op)
1103
+ // and no in-place string reuse; write the float64 directly.
1104
+ mem.updateNumberSlot(existing, value);
1105
+ bumpTarget(mem, key, target);
1106
+ return;
1107
+ }
1108
+ const isPrimitive = typeof value === "boolean" || typeof value === "bigint";
1109
+ if (isPrimitive) {
1110
+ // Fast path: primitives need no old-slot read (freeSlotRefs is a
1111
+ // no-op) and no in-place string reuse.
1112
+ if (typeof value === "boolean") {
1113
+ mem.updateBooleanSlot(existing, value);
1114
+ } else if (value >= MIN_INT64 && value <= MAX_INT64) {
1115
+ // Fast path: int64 bigints are written directly, avoiding the
1116
+ // ValueSlot object allocation of the generic path.
1117
+ mem.updateBigIntSlot(existing, value);
1118
+ } else {
1119
+ mem.updateValueSlot(existing, encodeValue(mem, value, arrayCapacity));
1120
+ }
1121
+ bumpTarget(mem, key, target);
1122
+ return;
1123
+ }
1124
+ if (typeof value === "string") {
1125
+ // Fast path: short strings are written inline into the slot itself,
1126
+ // avoiding a heap allocation and the string-table write. Longer strings
1127
+ // read the old string ref directly (no ValueSlot object allocation) so
1128
+ // storeStringValue can reuse the block in place.
1129
+ if (mem.updateInlineStringSlot(existing, value)) {
1130
+ bumpTarget(mem, key, target);
1131
+ return;
1132
+ }
1133
+ const oldType = mem.dataTable.getInt32(existing);
1134
+ const oldA = mem.dataTable.getInt32(existing + 4);
1135
+ const oldB = mem.dataTable.getInt32(existing + 8);
1136
+ const ref = mem.storeStringValue(
1137
+ value,
1138
+ oldType === TYPE_STRING_VALUE_REF
1139
+ ? { offset: oldA, length: oldB }
1140
+ : undefined,
709
1141
  );
710
- const offset = mem.createObject(props);
711
- mem.insert(key, offset);
1142
+ mem.updateValueSlot(existing, stringValueRefSlot(ref));
1143
+ bumpTarget(mem, key, target);
712
1144
  return;
713
1145
  }
1146
+ const oldSlot = mem.readValueSlot(existing);
1147
+ freeSlotRefs(mem, oldSlot);
1148
+ mem.updateValueSlot(
1149
+ existing,
1150
+ encodeValue(mem, value, arrayCapacity, oldSlot),
1151
+ );
1152
+ bumpTarget(mem, key, target);
1153
+ return;
1154
+ }
714
1155
 
715
- // Create a single value slot (primitive or array).
716
- const offset = mem.storeValueSlot(encodeValue(mem, value, arrayCapacity));
1156
+ if (value !== null && typeof value === "object" && !Array.isArray(value)) {
1157
+ // Create a new object.
1158
+ const valueObj = value as Record<string, unknown>;
1159
+ const props: Array<[string, ValueSlot]> = Object.entries(valueObj).map(
1160
+ ([name, v]) => [name, encodeValue(mem, v, arrayCapacity)],
1161
+ );
1162
+ const offset = mem.createObject(props);
717
1163
  mem.insert(key, offset);
718
- });
1164
+ const lv = mem.getLayoutVersion();
1165
+ offsetCache.set(key, { offset, layoutVersion: lv });
1166
+ if (target !== undefined) {
1167
+ target.__offset = offset;
1168
+ target.__layoutVersion = lv;
1169
+ target.__type = undefined;
1170
+ target.__arrayRef = undefined;
1171
+ }
1172
+ return;
1173
+ }
1174
+
1175
+ // Create a single value slot (primitive or array).
1176
+ const offset = mem.storeValueSlot(encodeValue(mem, value, arrayCapacity));
1177
+ mem.insert(key, offset);
1178
+ const lv = mem.getLayoutVersion();
1179
+ offsetCache.set(key, { offset, layoutVersion: lv });
1180
+ if (target !== undefined) {
1181
+ target.__offset = offset;
1182
+ target.__layoutVersion = lv;
1183
+ target.__type = undefined;
1184
+ target.__arrayRef = undefined;
1185
+ }
719
1186
  }
720
1187
 
721
1188
  /** Bump the version cell for a key, invalidating read caches. */
@@ -723,6 +1190,25 @@ function bump(mem: SharedMemory, key: string): void {
723
1190
  mem.bumpVersion(getIndex(mem, key));
724
1191
  }
725
1192
 
1193
+ /**
1194
+ * Bump the version cell for a key using the target's cached index, avoiding
1195
+ * the `getIndex` Map lookup on the write hot path. Falls back to `bump` when
1196
+ * the index is not yet cached.
1197
+ */
1198
+ function bumpTarget(
1199
+ mem: SharedMemory,
1200
+ key: string,
1201
+ target?: WriteTarget,
1202
+ ): void {
1203
+ if (target !== undefined && target.__index !== undefined) {
1204
+ mem.bumpVersion(target.__index);
1205
+ return;
1206
+ }
1207
+ const index = getIndex(mem, key);
1208
+ if (target !== undefined) target.__index = index;
1209
+ mem.bumpVersion(index);
1210
+ }
1211
+
726
1212
  /** Atomically update the value for a key. */
727
1213
  function updateValue<T>(
728
1214
  mem: SharedMemory,
@@ -730,7 +1216,7 @@ function updateValue<T>(
730
1216
  fn: (current: T) => T,
731
1217
  arrayCapacity?: number,
732
1218
  ): T {
733
- return mem.withLock(() => {
1219
+ return writeLock(mem, () => {
734
1220
  const current = readValue(mem, key) as T;
735
1221
  const next = fn(current);
736
1222
  writeValue(mem, key, next, arrayCapacity);
@@ -749,7 +1235,7 @@ function mutateValue<T, R>(
749
1235
  fn: (current: T) => R,
750
1236
  arrayCapacity?: number,
751
1237
  ): R {
752
- return mem.withLock(() => {
1238
+ return writeLock(mem, () => {
753
1239
  const current = readValue(mem, key) as T;
754
1240
  const result = fn(current);
755
1241
  writeValue(mem, key, current, arrayCapacity);
@@ -794,7 +1280,7 @@ function mutateArrayRef(
794
1280
  method: "push" | "pop",
795
1281
  args: unknown[],
796
1282
  ): unknown {
797
- return mem.withLock(() => {
1283
+ return writeLock(mem, () => {
798
1284
  const slotOffset = findArrayRefSlot(mem, key, prop);
799
1285
  if (slotOffset === null) {
800
1286
  throw new TypeError("Value is not an array");
@@ -822,6 +1308,8 @@ function mutateArrayRef(
822
1308
  // Read the last element before decrementing.
823
1309
  const lastSlot = mem.readArraySlot(ref, ref.count - 1);
824
1310
  const result = decodeValue(mem, lastSlot);
1311
+ // Free the removed element's referenced blocks (array/string).
1312
+ freeSlotRefs(mem, lastSlot);
825
1313
  const next = {
826
1314
  offset: ref.offset,
827
1315
  count: ref.count - 1,
@@ -883,6 +1371,159 @@ function createArrayProxy(
883
1371
  prop: string,
884
1372
  arrayCapacity?: number,
885
1373
  ): unknown[] {
1374
+ // Cache the object's property slot offset and the array ref so index writes
1375
+ // skip the per-write getObjectLayout Map lookup, properties.get, and the
1376
+ // DataTable header reads. Invalidated when the layout version changes
1377
+ // (defragmentation or array growth via moveArray).
1378
+ let cachedSlotOffset: number | undefined;
1379
+ let cachedRef:
1380
+ { offset: number; count: number; capacity: number } | undefined;
1381
+ let cachedLayoutVersion = -1;
1382
+ // Cached version-cell index for this key, so writes can bump the version
1383
+ // cell without a Map lookup on every write. Stable for the key's lifetime.
1384
+ let cachedIndex: number | undefined;
1385
+
1386
+ /** Bump the key's version cell using the cached index (resolved lazily). */
1387
+ function bumpCached(): void {
1388
+ if (cachedIndex === undefined) {
1389
+ cachedIndex = getIndex(mem, key);
1390
+ }
1391
+ mem.bumpVersion(cachedIndex);
1392
+ }
1393
+
1394
+ /**
1395
+ * Write a single array-property slot in place. Defined once (not per call)
1396
+ * so the set handler can inline the writeLock without allocating a closure
1397
+ * on every write. Mutates the cached slot offset/ref/layout version.
1398
+ */
1399
+ function writeArrayProp(arrProp: string | symbol, val: unknown): boolean {
1400
+ // Refresh the cached slot offset when the layout version changed
1401
+ // (defragmentation may have moved the object block).
1402
+ if (cachedLayoutVersion !== mem.getLayoutVersion()) {
1403
+ cachedSlotOffset = undefined;
1404
+ cachedRef = undefined;
1405
+ cachedLayoutVersion = mem.getLayoutVersion();
1406
+ }
1407
+ if (cachedSlotOffset === undefined) {
1408
+ const layout = getObjectLayout(mem, key);
1409
+ if (layout === null) {
1410
+ // Fallback: full read-modify-write.
1411
+ return mutateValue<Record<string, unknown>, boolean>(
1412
+ mem,
1413
+ key,
1414
+ (fresh) => {
1415
+ const arr = fresh[prop] as unknown[];
1416
+ (arr as unknown as Record<string | symbol, unknown>)[arrProp] = val;
1417
+ return true;
1418
+ },
1419
+ arrayCapacity,
1420
+ );
1421
+ }
1422
+ const so = layout.properties.get(prop);
1423
+ if (so === undefined) {
1424
+ throw new TypeError(
1425
+ `Value for key "${key}" has a fixed shape: properties may not be added or removed`,
1426
+ );
1427
+ }
1428
+ cachedSlotOffset = so;
1429
+ }
1430
+ const propSlot = mem.readValueSlot(cachedSlotOffset);
1431
+ if (
1432
+ propSlot.type === TYPE_ARRAY_REF &&
1433
+ typeof arrProp === "string" &&
1434
+ /^\d+$/.test(arrProp)
1435
+ ) {
1436
+ const index = Number(arrProp);
1437
+ // Use the cached ref when valid; re-read when the array may have
1438
+ // grown past the cached count (push) or the layout changed.
1439
+ let ref = cachedRef;
1440
+ if (
1441
+ ref === undefined ||
1442
+ index >= ref.count ||
1443
+ cachedLayoutVersion !== mem.getLayoutVersion()
1444
+ ) {
1445
+ ref = {
1446
+ offset: propSlot.a,
1447
+ count: propSlot.b,
1448
+ capacity: propSlot.c,
1449
+ };
1450
+ cachedRef = ref;
1451
+ }
1452
+ if (index >= ref.count) {
1453
+ throw new RangeError(
1454
+ `Index ${index} out of bounds for array of length ${ref.count}`,
1455
+ );
1456
+ }
1457
+ if (typeof val === "number") {
1458
+ // Fast path: numbers need no old-slot read (freeSlotRefs is a no-op).
1459
+ mem.updateArrayNumberSlot(ref, index, val);
1460
+ bumpCached();
1461
+ return true;
1462
+ }
1463
+ if (typeof val === "boolean") {
1464
+ // Fast path: booleans need no old-slot read (freeSlotRefs is a no-op).
1465
+ mem.updateArrayBooleanSlot(ref, index, val);
1466
+ bumpCached();
1467
+ return true;
1468
+ }
1469
+ if (typeof val === "bigint" && val >= MIN_INT64 && val <= MAX_INT64) {
1470
+ // Fast path: int64 bigints are written directly, avoiding the
1471
+ // ValueSlot object allocation of the generic path.
1472
+ mem.updateArrayBigIntSlot(ref, index, val);
1473
+ bumpCached();
1474
+ return true;
1475
+ }
1476
+ if (typeof val === "string") {
1477
+ // Fast path: short strings are written inline into the slot.
1478
+ if (mem.updateArrayInlineStringSlot(ref, index, val)) {
1479
+ bumpCached();
1480
+ return true;
1481
+ }
1482
+ }
1483
+ freeSlotRefs(mem, mem.readArraySlot(ref, index));
1484
+ mem.updateArraySlot(ref, index, encodeValue(mem, val));
1485
+ bumpCached();
1486
+ return true;
1487
+ }
1488
+ // Array property length write: shrink/grow in place without re-encoding
1489
+ // the whole object (which would leave a dead slot).
1490
+ if (propSlot.type === TYPE_ARRAY_REF && arrProp === "length") {
1491
+ const newLen = Number(val);
1492
+ if (!Number.isInteger(newLen) || newLen < 0) {
1493
+ throw new RangeError("Invalid array length");
1494
+ }
1495
+ const ref = {
1496
+ offset: propSlot.a,
1497
+ count: propSlot.b,
1498
+ capacity: propSlot.c,
1499
+ };
1500
+ if (newLen <= ref.capacity) {
1501
+ mem.updateValueSlot(
1502
+ cachedSlotOffset,
1503
+ arrayRefSlot({
1504
+ offset: ref.offset,
1505
+ count: newLen,
1506
+ capacity: ref.capacity,
1507
+ }),
1508
+ );
1509
+ bumpCached();
1510
+ return true;
1511
+ }
1512
+ // Growing beyond capacity falls through to the full read-modify-write.
1513
+ }
1514
+ // Fallback: full read-modify-write.
1515
+ return mutateValue<Record<string, unknown>, boolean>(
1516
+ mem,
1517
+ key,
1518
+ (fresh) => {
1519
+ const arr = fresh[prop] as unknown[];
1520
+ (arr as unknown as Record<string | symbol, unknown>)[arrProp] = val;
1521
+ return true;
1522
+ },
1523
+ arrayCapacity,
1524
+ );
1525
+ }
1526
+
886
1527
  return new Proxy([] as unknown[], {
887
1528
  get(_target, arrProp) {
888
1529
  // push/pop on an array property: operate directly on the array ref
@@ -939,73 +1580,18 @@ function createArrayProxy(
939
1580
  },
940
1581
  set(_target, arrProp, val) {
941
1582
  // In-place update: write only the affected array slot instead of
942
- // 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));
966
- bump(mem, key);
967
- return true;
968
- }
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
- }
996
- }
997
- // Fallback: full read-modify-write.
998
- return mutateValue<Record<string, unknown>, boolean>(
999
- mem,
1000
- key,
1001
- (fresh) => {
1002
- const arr = fresh[prop] as unknown[];
1003
- (arr as unknown as Record<string | symbol, unknown>)[arrProp] = val;
1004
- return true;
1005
- },
1006
- arrayCapacity,
1007
- );
1008
- });
1583
+ // decoding and re-encoding the whole object. Inline writeLock to avoid
1584
+ // a closure allocation on the hot path.
1585
+ mem.beginWrite();
1586
+ if (mem.isDefragmenting()) {
1587
+ mem.endWrite();
1588
+ return mem.withLock(() => writeArrayProp(arrProp, val));
1589
+ }
1590
+ try {
1591
+ return writeArrayProp(arrProp, val);
1592
+ } finally {
1593
+ mem.endWrite();
1594
+ }
1009
1595
  },
1010
1596
  });
1011
1597
  }