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/memory.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import {
2
2
  GrowableBuffer,
3
+ MIN_BLOCK_SIZE,
3
4
  SharedBudget,
4
5
  nextPowerOfTwo,
5
6
  } from "./growable-buffer.ts";
@@ -48,6 +49,13 @@ export const TYPE_STRING_REF = 3;
48
49
  export const TYPE_ARRAY_REF = 4;
49
50
  export const TYPE_OBJECT = 5;
50
51
  export const TYPE_BIGINT_STR = 6;
52
+ /** A string value stored in the ArrayTable (slab-allocated, refcounted). */
53
+ export const TYPE_STRING_VALUE_REF = 7;
54
+ /**
55
+ * A short string stored inline in the slot itself (up to 12 UTF-8 bytes in
56
+ * the a/b/c fields), avoiding a heap allocation and the string-table write.
57
+ */
58
+ export const TYPE_INLINE_STRING = 8;
51
59
 
52
60
  const SLOT_SIZE = 16;
53
61
  const OBJECT_HEADER = 16;
@@ -149,6 +157,11 @@ export function stringRefSlot(ref: StringRef): ValueSlot {
149
157
  return { type: TYPE_STRING_REF, a: ref.offset, b: ref.length, c: 0 };
150
158
  }
151
159
 
160
+ /** Encode a string value (in the ArrayTable) into a ValueSlot. */
161
+ export function stringValueRefSlot(ref: StringRef): ValueSlot {
162
+ return { type: TYPE_STRING_VALUE_REF, a: ref.offset, b: ref.length, c: 0 };
163
+ }
164
+
152
165
  /** Encode a bigint stored as a decimal string into a ValueSlot. */
153
166
  export function bigintStrSlot(ref: StringRef): ValueSlot {
154
167
  return { type: TYPE_BIGINT_STR, a: ref.offset, b: ref.length, c: 0 };
@@ -176,6 +189,8 @@ export function decodeSlot(slot: ValueSlot): unknown {
176
189
  }
177
190
  case TYPE_STRING_REF:
178
191
  return { offset: slot.a, length: slot.b };
192
+ case TYPE_STRING_VALUE_REF:
193
+ return { offset: slot.a, length: slot.b };
179
194
  case TYPE_ARRAY_REF:
180
195
  return { offset: slot.a, count: slot.b, capacity: slot.c };
181
196
  default:
@@ -217,6 +232,11 @@ export interface SharedMemoryOptions {
217
232
  stringTable?: SharedArrayBuffer;
218
233
  arrayTable?: SharedArrayBuffer;
219
234
  };
235
+ freeLists?: {
236
+ dataTable?: SharedArrayBuffer;
237
+ stringTable?: SharedArrayBuffer;
238
+ arrayTable?: SharedArrayBuffer;
239
+ };
220
240
  };
221
241
  }
222
242
 
@@ -230,6 +250,7 @@ function makeBuffer(
230
250
  singleBuffer = false,
231
251
  chargeInitial = true,
232
252
  cursorBuffer?: SharedArrayBuffer,
253
+ freeListBuffer?: SharedArrayBuffer,
233
254
  ): GrowableBuffer {
234
255
  if (buf === undefined) {
235
256
  return new GrowableBuffer(
@@ -240,6 +261,7 @@ function makeBuffer(
240
261
  singleBuffer,
241
262
  chargeInitial,
242
263
  cursorBuffer,
264
+ freeListBuffer,
243
265
  );
244
266
  }
245
267
  return new GrowableBuffer(
@@ -250,6 +272,7 @@ function makeBuffer(
250
272
  singleBuffer,
251
273
  chargeInitial,
252
274
  cursorBuffer,
275
+ freeListBuffer,
253
276
  );
254
277
  }
255
278
 
@@ -274,7 +297,7 @@ export class SharedMemory {
274
297
  * on growth. The version is incremented on every write so workers can
275
298
  * invalidate their read caches cross-worker.
276
299
  */
277
- private versionView: Int32Array;
300
+ versionView: Int32Array;
278
301
  /**
279
302
  * Global layout version cell. Bumped by the main thread after a
280
303
  * defragmentation pass so workers can invalidate cached offsets (indexCache,
@@ -380,6 +403,7 @@ export class SharedMemory {
380
403
  false,
381
404
  chargeInitial,
382
405
  buffers?.cursors?.dataTable,
406
+ buffers?.freeLists?.dataTable,
383
407
  );
384
408
  this.stringTable = makeBuffer(
385
409
  buffers?.stringTable,
@@ -390,6 +414,7 @@ export class SharedMemory {
390
414
  false,
391
415
  chargeInitial,
392
416
  buffers?.cursors?.stringTable,
417
+ buffers?.freeLists?.stringTable,
393
418
  );
394
419
  this.arrayTable = makeBuffer(
395
420
  buffers?.arrayTable,
@@ -400,11 +425,12 @@ export class SharedMemory {
400
425
  false,
401
426
  chargeInitial,
402
427
  buffers?.cursors?.arrayTable,
428
+ buffers?.freeLists?.arrayTable,
403
429
  );
404
430
  this.versionView = new Int32Array(this.indexTable.buffers[0]!);
405
431
  this.layoutVersion = new Int32Array(
406
432
  buffers?.layoutVersion ??
407
- new SharedArrayBuffer(Int32Array.BYTES_PER_ELEMENT),
433
+ new SharedArrayBuffer(Int32Array.BYTES_PER_ELEMENT * 3),
408
434
  );
409
435
  this.lock = options?.lock ?? new Mutex();
410
436
  this.refCountBuffer = buffers?.refCount ?? null;
@@ -473,6 +499,11 @@ export class SharedMemory {
473
499
  stringTable: SharedArrayBuffer;
474
500
  arrayTable: SharedArrayBuffer;
475
501
  };
502
+ freeLists: {
503
+ dataTable: SharedArrayBuffer;
504
+ stringTable: SharedArrayBuffer;
505
+ arrayTable: SharedArrayBuffer;
506
+ };
476
507
  } {
477
508
  return {
478
509
  indexTable: this.indexTable.buffers,
@@ -492,6 +523,11 @@ export class SharedMemory {
492
523
  stringTable: this.stringTable.cursorBuffer,
493
524
  arrayTable: this.arrayTable.cursorBuffer,
494
525
  },
526
+ freeLists: {
527
+ dataTable: this.dataTable.freeListBuffer,
528
+ stringTable: this.stringTable.freeListBuffer,
529
+ arrayTable: this.arrayTable.freeListBuffer,
530
+ },
495
531
  };
496
532
  }
497
533
 
@@ -517,6 +553,11 @@ export class SharedMemory {
517
553
  stringTable?: SharedArrayBuffer;
518
554
  arrayTable?: SharedArrayBuffer;
519
555
  };
556
+ freeLists?: {
557
+ dataTable?: SharedArrayBuffer;
558
+ stringTable?: SharedArrayBuffer;
559
+ arrayTable?: SharedArrayBuffer;
560
+ };
520
561
  }): SharedMemory {
521
562
  const indexBuffer = Array.isArray(buffers.indexTable)
522
563
  ? buffers.indexTable[0]
@@ -538,6 +579,7 @@ export class SharedMemory {
538
579
  refCount: buffers.refCount ?? undefined,
539
580
  budget: buffers.budget ?? undefined,
540
581
  cursors: buffers.cursors,
582
+ freeLists: buffers.freeLists,
541
583
  },
542
584
  lock: new Mutex(buffers.lock),
543
585
  });
@@ -574,10 +616,8 @@ export class SharedMemory {
574
616
  }
575
617
 
576
618
  const bytes = this.encoder.encode(value);
577
- const offset = this.stringTable.byteLength;
578
- this.stringTable.ensureCapacity(offset + bytes.byteLength);
619
+ const offset = this.stringTable.reserve(bytes.byteLength);
579
620
  this.stringTable.writeBytes(offset, bytes);
580
- this.stringTable.advance(bytes.byteLength);
581
621
  const ref: StringRef = { offset, length: bytes.byteLength };
582
622
 
583
623
  // Insert into the cache, growing capacity up to `internMax` before evicting.
@@ -615,6 +655,86 @@ export class SharedMemory {
615
655
  return this.decoder.decode(bytes);
616
656
  }
617
657
 
658
+ /**
659
+ * Store a string value in the ArrayTable as a slab-allocated block.
660
+ *
661
+ * Unlike `storeString` (which interns into the append-only StringTable for
662
+ * property names and keys), string values are stored in the ArrayTable so
663
+ * they can be freed and reused when their reference is overwritten. Each
664
+ * block starts with a 4-byte objectId header (for refcounting), followed by
665
+ * the UTF-8 bytes of the string.
666
+ *
667
+ * @returns a StringRef pointing into the ArrayTable.
668
+ */
669
+ storeStringValue(value: string, oldRef?: StringRef): StringRef {
670
+ const bytes = this.encoder.encode(value);
671
+ const size = ARRAY_HEADER + bytes.byteLength;
672
+ // In-place update: if the new string fits in the existing block (same
673
+ // length or shorter), overwrite it without allocating a new block. This
674
+ // keeps repeated writes to the same string property cheap and avoids
675
+ // unbounded ArrayTable growth. The block's objectId header is preserved.
676
+ if (oldRef !== undefined && size <= ARRAY_HEADER + oldRef.length) {
677
+ this.arrayTable.writeBytes(oldRef.offset + ARRAY_HEADER, bytes);
678
+ return { offset: oldRef.offset, length: bytes.byteLength };
679
+ }
680
+ const offset = this.arrayTable.reserve(size);
681
+ const objectId = this.nextObjectId++;
682
+ this.arrayTable.setInt32(offset, objectId);
683
+ this.arrayTable.writeBytes(offset + ARRAY_HEADER, bytes);
684
+ return { offset, length: bytes.byteLength };
685
+ }
686
+
687
+ /** Read a string value from the ArrayTable by reference. */
688
+ readStringValue(ref: StringRef): string {
689
+ const bytes = this.arrayTable.readBytes(
690
+ ref.offset + ARRAY_HEADER,
691
+ ref.length,
692
+ );
693
+ return this.decoder.decode(bytes);
694
+ }
695
+
696
+ /** Read a short string stored inline in a DataTable slot. */
697
+ readInlineString(a: number, b: number, c: number): string {
698
+ const length = c >>> 28;
699
+ const bytes = new Uint8Array(length);
700
+ for (let i = 0; i < length; i++) {
701
+ const word = i < 4 ? a : i < 8 ? b : c;
702
+ bytes[i] = (word >>> ((i % 4) * 8)) & 0xff;
703
+ }
704
+ return this.decoder.decode(bytes);
705
+ }
706
+
707
+ /**
708
+ * Free a string value block in the ArrayTable, pushing it onto the free
709
+ * list so it can be reused. Detects double frees.
710
+ *
711
+ * No-op: string-value blocks are no longer freed on the hot path (they are
712
+ * reclaimed by defragmentation instead), so this is kept only for API
713
+ * compatibility.
714
+ */
715
+ freeStringValue(_ref: StringRef): void {
716
+ // Intentionally a no-op; dead string blocks are reclaimed by defrag.
717
+ }
718
+
719
+ /**
720
+ * Release a reference to a string value.
721
+ *
722
+ * No-op: string-value refcounting is disabled. Blocks are never freed on the
723
+ * hot path; dead space is reclaimed by defragmentation.
724
+ */
725
+ releaseStringValue(_ref: StringRef): void {
726
+ // Intentionally a no-op; see freeStringValue.
727
+ }
728
+
729
+ /**
730
+ * Acquire a reference to a string value.
731
+ *
732
+ * No-op: string-value refcounting is disabled.
733
+ */
734
+ acquireStringValue(_ref: StringRef): void {
735
+ // Intentionally a no-op; see freeStringValue.
736
+ }
737
+
618
738
  // ---------------------------------------------------------------------------
619
739
  // ArrayTable
620
740
  // ---------------------------------------------------------------------------
@@ -631,8 +751,8 @@ export class SharedMemory {
631
751
  storeArray(slots: ValueSlot[], capacity?: number): ArrayRef {
632
752
  const count = slots.length;
633
753
  const cap = Math.max(capacity ?? 0, count, 1);
634
- const offset = this.arrayTable.byteLength;
635
- this.arrayTable.ensureCapacity(offset + ARRAY_HEADER + cap * SLOT_SIZE);
754
+ const size = ARRAY_HEADER + cap * SLOT_SIZE;
755
+ const offset = this.arrayTable.reserve(size);
636
756
  const objectId = this.nextObjectId++;
637
757
  this.arrayTable.setInt32(offset, objectId);
638
758
  for (let i = 0; i < count; i++) {
@@ -642,7 +762,6 @@ export class SharedMemory {
642
762
  slots[i]!,
643
763
  );
644
764
  }
645
- this.arrayTable.advance(ARRAY_HEADER + cap * SLOT_SIZE);
646
765
  return { offset, count, capacity: cap };
647
766
  }
648
767
 
@@ -666,6 +785,77 @@ export class SharedMemory {
666
785
  );
667
786
  }
668
787
 
788
+ /**
789
+ * Fast-path: write a float64 number into an array slot directly, avoiding
790
+ * the ValueSlot object allocation and the 4 separate Int32 writes.
791
+ */
792
+ updateArrayNumberSlot(ref: ArrayRef, index: number, value: number): void {
793
+ slotView.setFloat64(0, value, true);
794
+ const buf = this.arrayTable;
795
+ const off = ref.offset + ARRAY_HEADER + index * SLOT_SIZE;
796
+ buf.setInt32(off, TYPE_NUMBER);
797
+ buf.setInt32(off + 4, slotView.getInt32(0, true));
798
+ buf.setInt32(off + 8, slotView.getInt32(4, true));
799
+ buf.setInt32(off + 12, 0);
800
+ }
801
+
802
+ /**
803
+ * Fast-path: write a boolean into an array slot directly, avoiding the
804
+ * ValueSlot object allocation and the 4 separate Int32 writes.
805
+ */
806
+ updateArrayBooleanSlot(ref: ArrayRef, index: number, value: boolean): void {
807
+ const buf = this.arrayTable;
808
+ const off = ref.offset + ARRAY_HEADER + index * SLOT_SIZE;
809
+ buf.setInt32(off, TYPE_BOOLEAN);
810
+ buf.setInt32(off + 4, value ? 1 : 0);
811
+ buf.setInt32(off + 8, 0);
812
+ buf.setInt32(off + 12, 0);
813
+ }
814
+
815
+ /**
816
+ * Fast-path: write an int64 bigint into an array slot directly, avoiding
817
+ * the ValueSlot object allocation and the 4 separate Int32 writes.
818
+ */
819
+ updateArrayBigIntSlot(ref: ArrayRef, index: number, value: bigint): void {
820
+ slotView.setBigInt64(0, value, true);
821
+ const buf = this.arrayTable;
822
+ const off = ref.offset + ARRAY_HEADER + index * SLOT_SIZE;
823
+ buf.setInt32(off, TYPE_BIGINT);
824
+ buf.setInt32(off + 4, slotView.getInt32(0, true));
825
+ buf.setInt32(off + 8, slotView.getInt32(4, true));
826
+ buf.setInt32(off + 12, 0);
827
+ }
828
+
829
+ /**
830
+ * Fast-path: write a short string inline into an array slot (up to 11
831
+ * UTF-8 bytes), avoiding a heap allocation and the string-table write.
832
+ * Returns true if the string fit inline; false if it must go to the heap.
833
+ */
834
+ updateArrayInlineStringSlot(
835
+ ref: ArrayRef,
836
+ index: number,
837
+ value: string,
838
+ ): boolean {
839
+ const bytes = this.encoder.encode(value);
840
+ if (bytes.byteLength > 11) return false;
841
+ const buf = this.arrayTable;
842
+ const off = ref.offset + ARRAY_HEADER + index * SLOT_SIZE;
843
+ buf.setInt32(off, TYPE_INLINE_STRING);
844
+ let a = 0;
845
+ let b = 0;
846
+ let c = bytes.byteLength << 28;
847
+ for (let i = 0; i < bytes.byteLength; i++) {
848
+ const byte = bytes[i]!;
849
+ if (i < 4) a |= byte << (i * 8);
850
+ else if (i < 8) b |= byte << ((i - 4) * 8);
851
+ else c |= byte << ((i - 8) * 8);
852
+ }
853
+ buf.setInt32(off + 4, a);
854
+ buf.setInt32(off + 8, b);
855
+ buf.setInt32(off + 12, c);
856
+ return true;
857
+ }
858
+
669
859
  /** Read a single value slot of an array from the ArrayTable. */
670
860
  readArraySlot(ref: ArrayRef, index: number): ValueSlot {
671
861
  return readSlot(
@@ -685,10 +875,8 @@ export class SharedMemory {
685
875
  * the DataTable to point at the new location.
686
876
  */
687
877
  moveArray(ref: ArrayRef, newCapacity: number): ArrayRef {
688
- const newOffset = this.arrayTable.byteLength;
689
- this.arrayTable.ensureCapacity(
690
- newOffset + ARRAY_HEADER + newCapacity * SLOT_SIZE,
691
- );
878
+ const size = ARRAY_HEADER + newCapacity * SLOT_SIZE;
879
+ const newOffset = this.arrayTable.reserve(size);
692
880
  const objectId = this.getArrayObjectId(ref.offset);
693
881
  this.arrayTable.setInt32(newOffset, objectId);
694
882
  for (let i = 0; i < ref.count; i++) {
@@ -698,7 +886,13 @@ export class SharedMemory {
698
886
  readSlot(this.arrayTable, ref.offset + ARRAY_HEADER + i * SLOT_SIZE),
699
887
  );
700
888
  }
701
- this.arrayTable.advance(ARRAY_HEADER + newCapacity * SLOT_SIZE);
889
+ // The old block is no longer referenced; it is reclaimed by defrag.
890
+ this.freeArray(ref);
891
+ // The array moved to a new offset, so any cached ArrayRef (e.g. the
892
+ // per-target `__arrayRef` on the write hot path) is now stale. Bump the
893
+ // global layout version so those caches are invalidated, exactly as a
894
+ // defragmentation pass does.
895
+ this.bumpLayoutVersion();
702
896
  return { offset: newOffset, count: ref.count, capacity: newCapacity };
703
897
  }
704
898
 
@@ -734,16 +928,23 @@ export class SharedMemory {
734
928
  };
735
929
  }
736
930
 
931
+ /**
932
+ * Free an array block, pushing it onto the ArrayTable free list so it can
933
+ * be reused. Detects double frees. The block's data is overwritten with
934
+ * free-list metadata, so it must not be read after freeing.
935
+ */
936
+ freeArray(_ref: ArrayRef): void {
937
+ // No-op: array blocks are reclaimed by defrag, not the free list.
938
+ }
939
+
737
940
  // ---------------------------------------------------------------------------
738
941
  // DataTable
739
942
  // ---------------------------------------------------------------------------
740
943
 
741
944
  /** Store a single value slot in the DataTable and return its offset. */
742
945
  storeValueSlot(slot: ValueSlot): number {
743
- const offset = this.dataTable.byteLength;
744
- this.dataTable.ensureCapacity(offset + SLOT_SIZE);
946
+ const offset = this.dataTable.reserve(SLOT_SIZE);
745
947
  writeSlot(this.dataTable, offset, slot);
746
- this.dataTable.advance(SLOT_SIZE);
747
948
  return offset;
748
949
  }
749
950
 
@@ -757,6 +958,75 @@ export class SharedMemory {
757
958
  writeSlot(this.dataTable, offset, slot);
758
959
  }
759
960
 
961
+ /**
962
+ * Fast-path: write a float64 number slot directly into the DataTable,
963
+ * avoiding the ValueSlot object allocation and the 4 separate Int32
964
+ * writes of the generic path.
965
+ */
966
+ updateNumberSlot(offset: number, value: number): void {
967
+ slotView.setFloat64(0, value, true);
968
+ const buf = this.dataTable;
969
+ buf.setInt32(offset, TYPE_NUMBER);
970
+ buf.setInt32(offset + 4, slotView.getInt32(0, true));
971
+ buf.setInt32(offset + 8, slotView.getInt32(4, true));
972
+ buf.setInt32(offset + 12, 0);
973
+ }
974
+
975
+ /**
976
+ * Fast-path: write a boolean slot directly into the DataTable, avoiding
977
+ * the ValueSlot object allocation of the generic path.
978
+ */
979
+ updateBooleanSlot(offset: number, value: boolean): void {
980
+ const buf = this.dataTable;
981
+ buf.setInt32(offset, TYPE_BOOLEAN);
982
+ buf.setInt32(offset + 4, value ? 1 : 0);
983
+ buf.setInt32(offset + 8, 0);
984
+ buf.setInt32(offset + 12, 0);
985
+ }
986
+
987
+ /**
988
+ * Fast-path: write an int64 bigint slot directly into the DataTable,
989
+ * avoiding the ValueSlot object allocation of the generic path.
990
+ */
991
+ updateBigIntSlot(offset: number, value: bigint): void {
992
+ slotView.setBigInt64(0, value, true);
993
+ const buf = this.dataTable;
994
+ buf.setInt32(offset, TYPE_BIGINT);
995
+ buf.setInt32(offset + 4, slotView.getInt32(0, true));
996
+ buf.setInt32(offset + 8, slotView.getInt32(4, true));
997
+ buf.setInt32(offset + 12, 0);
998
+ }
999
+
1000
+ /**
1001
+ * Fast-path: write a short string inline into the slot itself (up to 11
1002
+ * UTF-8 bytes), avoiding a heap allocation and the string-table write.
1003
+ * The bytes are packed into the slot's a/b/c fields (11 bytes) with the
1004
+ * length in the top 4 bits of `c`, so they can be read back without
1005
+ * touching the DataTable again.
1006
+ * Returns true if the string fit inline; false if it must go to the heap.
1007
+ */
1008
+ updateInlineStringSlot(offset: number, value: string): boolean {
1009
+ const bytes = this.encoder.encode(value);
1010
+ if (bytes.byteLength > 11) return false;
1011
+ const buf = this.dataTable;
1012
+ buf.setInt32(offset, TYPE_INLINE_STRING);
1013
+ // Pack length into the top 4 bits of `c`; bytes 0-3 in `a`, 4-7 in `b`,
1014
+ // 8-10 in the low 28 bits of `c`.
1015
+ let a = 0;
1016
+ let b = 0;
1017
+ let c = bytes.byteLength << 28;
1018
+ for (let i = 0; i < bytes.byteLength; i++) {
1019
+ const byte = bytes[i]!;
1020
+ if (i < 4) a |= byte << (i * 8);
1021
+ else if (i < 8) b |= byte << ((i - 4) * 8);
1022
+ else c |= byte << ((i - 8) * 8);
1023
+ }
1024
+ buf.setInt32(offset + 4, a);
1025
+ buf.setInt32(offset + 8, b);
1026
+ buf.setInt32(offset + 12, c);
1027
+ return true;
1028
+ }
1029
+
760
1030
  /**
761
1031
  * Create an object in the DataTable.
762
1032
  *
@@ -766,8 +1036,7 @@ export class SharedMemory {
766
1036
  createObject(properties: Array<[string, ValueSlot]>): number {
767
1037
  const blockSize =
768
1038
  OBJECT_HEADER + properties.length * (NAME_REF_SIZE + SLOT_SIZE);
769
- const offset = this.dataTable.byteLength;
770
- this.dataTable.ensureCapacity(offset + blockSize);
1039
+ const offset = this.dataTable.reserve(blockSize);
771
1040
 
772
1041
  const objectId = this.nextObjectId++;
773
1042
  this.dataTable.setInt32(offset, TYPE_OBJECT);
@@ -783,7 +1052,6 @@ export class SharedMemory {
783
1052
  writeSlot(this.dataTable, cursor, slot);
784
1053
  cursor += SLOT_SIZE;
785
1054
  }
786
- this.dataTable.advance(blockSize);
787
1055
  return offset;
788
1056
  }
789
1057
 
@@ -809,6 +1077,32 @@ export class SharedMemory {
809
1077
  return this.dataTable.getInt32(offset + 8);
810
1078
  }
811
1079
 
1080
+ /**
1081
+ * Free a single value slot in the DataTable, pushing it onto the free list
1082
+ * so it can be reused. Detects double frees.
1083
+ */
1084
+ freeValueSlot(_offset: number): void {
1085
+ // No-op: value slots are reclaimed by defrag, not the free list.
1086
+ }
1087
+
1088
+ /**
1089
+ * Free an object block in the DataTable, pushing it onto the free list so
1090
+ * it can be reused. Detects double frees. The block's data is overwritten
1091
+ * with free-list metadata, so it must not be read after freeing.
1092
+ */
1093
+ freeObject(_offset: number): void {
1094
+ // No-op: object blocks are reclaimed by defrag, not the free list.
1095
+ }
1096
+
1097
+ /**
1098
+ * Free a string in the StringTable, pushing it onto the free list so it can
1099
+ * be reused. Detects double frees. The string's bytes are overwritten with
1100
+ * free-list metadata, so it must not be read after freeing.
1101
+ */
1102
+ freeString(_ref: StringRef): void {
1103
+ // No-op: string blocks are reclaimed by defrag, not the free list.
1104
+ }
1105
+
812
1106
  /**
813
1107
  * Get the stable layout of an object value: its total DataTable offset and
814
1108
  * the slot offset of each property.
@@ -895,6 +1189,15 @@ export class SharedMemory {
895
1189
  return null;
896
1190
  }
897
1191
 
1192
+ /**
1193
+ * Int32Array index of a key's version cell within the Index Table buffer.
1194
+ * Stable for the lifetime of the key (the Index Table is never reordered or
1195
+ * replaced), so callers may cache it and load the version directly.
1196
+ */
1197
+ getVersionCellIndex(index: number): number {
1198
+ return versionCellIndex(index);
1199
+ }
1200
+
898
1201
  /** Read the current version for a key index (lock-free). */
899
1202
  getVersion(index: number): number {
900
1203
  return Atomics.load(this.versionView, versionCellIndex(index));
@@ -908,7 +1211,13 @@ export class SharedMemory {
908
1211
  /** Insert a key → dataOffset mapping into the Index Table. */
909
1212
  insert(key: string, dataOffset: number): void {
910
1213
  const keyRef = this.storeString(key);
911
- const count = this.indexTable.getInt32(0);
1214
+ // Atomically reserve the next entry index. The count cell (index 0) is
1215
+ // incremented with a single Atomics.add, so concurrent workers each get a
1216
+ // distinct entry slot without a mutex. The Index Table is a single
1217
+ // growable SAB, so a fresh Int32Array view over its current buffer is
1218
+ // correct (insert is rare — only on first write of a key).
1219
+ const view = new Int32Array(this.indexTable.buffers[0]!);
1220
+ const count = Atomics.add(view, 0, 1);
912
1221
  const entryOffset = INDEX_HEADER + count * INDEX_ENTRY_SIZE;
913
1222
  this.indexTable.ensureCapacity(entryOffset + INDEX_ENTRY_SIZE);
914
1223
  this.indexTable.setInt32(entryOffset, keyRef.offset);
@@ -916,8 +1225,6 @@ export class SharedMemory {
916
1225
  this.indexTable.setInt32(entryOffset + 8, dataOffset);
917
1226
  // The 4th field is the key's version cell, initialized to 0.
918
1227
  this.indexTable.setInt32(entryOffset + INDEX_VERSION_OFFSET, 0);
919
- this.indexTable.setInt32(0, count + 1);
920
- this.indexTable.advance(INDEX_ENTRY_SIZE);
921
1228
 
922
1229
  // Rebuild the version view over the first chunk (the Index Table is small
923
1230
  // and stays within the first chunk for typical workloads; version cells
@@ -939,6 +1246,49 @@ export class SharedMemory {
939
1246
  Atomics.add(this.layoutVersion, 0, 1);
940
1247
  }
941
1248
 
1249
+ // ---------------------------------------------------------------------------
1250
+ // Lock-free write coordination (write counter + defrag flag)
1251
+ // ---------------------------------------------------------------------------
1252
+ // Cell 0 of `layoutVersion` is the global layout version. Cells 1 and 2 are
1253
+ // used to coordinate lock-free writes against the (rare) defragmentation
1254
+ // pass: cell 1 is a count of in-flight lock-free writes, cell 2 is a flag
1255
+ // set while a defragmentation pass is running. A write increments the
1256
+ // counter, checks the flag, and if the flag is set it falls back to the
1257
+ // mutex. The defrag sets the flag, waits for the counter to reach zero, then
1258
+ // runs under the mutex.
1259
+
1260
+ /** Enter a lock-free write: increment the in-flight write counter. */
1261
+ beginWrite(): void {
1262
+ Atomics.add(this.layoutVersion, 1, 1);
1263
+ }
1264
+
1265
+ /** Leave a lock-free write: decrement the in-flight write counter. */
1266
+ endWrite(): void {
1267
+ Atomics.sub(this.layoutVersion, 1, 1);
1268
+ }
1269
+
1270
+ /** Whether a defragmentation pass is currently running. */
1271
+ isDefragmenting(): boolean {
1272
+ return Atomics.load(this.layoutVersion, 2) !== 0;
1273
+ }
1274
+
1275
+ /** Set the defragmentation-in-progress flag. */
1276
+ setDefragmenting(): void {
1277
+ Atomics.store(this.layoutVersion, 2, 1);
1278
+ }
1279
+
1280
+ /** Clear the defragmentation-in-progress flag. */
1281
+ clearDefragmenting(): void {
1282
+ Atomics.store(this.layoutVersion, 2, 0);
1283
+ }
1284
+
1285
+ /** Wait until no lock-free writes are in flight. */
1286
+ waitForWritesToFinish(): void {
1287
+ while (Atomics.load(this.layoutVersion, 1) !== 0) {
1288
+ Atomics.wait(this.layoutVersion, 1, Atomics.load(this.layoutVersion, 1));
1289
+ }
1290
+ }
1291
+
942
1292
  // ---------------------------------------------------------------------------
943
1293
  // Defragmentation
944
1294
  // ---------------------------------------------------------------------------
@@ -971,6 +1321,10 @@ export class SharedMemory {
971
1321
  const liveArrays = new Set<number>();
972
1322
  const liveObjects = new Set<number>();
973
1323
  const liveDataOffsets = new Set<number>();
1324
+ /** Byte size of each live ArrayTable block (array or string value). */
1325
+ const arrayBlockSizes = new Map<number, number>();
1326
+ /** Offsets of live string-value blocks (no slots to remap). */
1327
+ const stringValueBlocks = new Set<number>();
974
1328
 
975
1329
  const markString = (offset: number) => liveStrings.add(offset);
976
1330
 
@@ -980,15 +1334,27 @@ export class SharedMemory {
980
1334
  case TYPE_BIGINT_STR:
981
1335
  liveStrings.add(slot.a);
982
1336
  break;
1337
+ case TYPE_STRING_VALUE_REF:
1338
+ liveArrays.add(slot.a);
1339
+ stringValueBlocks.add(slot.a);
1340
+ arrayBlockSizes.set(
1341
+ slot.a,
1342
+ Math.max(ARRAY_HEADER + slot.b, MIN_BLOCK_SIZE),
1343
+ );
1344
+ break;
983
1345
  case TYPE_ARRAY_REF:
984
- markArray(slot.a, slot.b);
1346
+ markArray(slot.a, slot.b, slot.c);
985
1347
  break;
986
1348
  }
987
1349
  };
988
1350
 
989
- const markArray = (offset: number, count: number) => {
1351
+ const markArray = (offset: number, count: number, capacity: number) => {
990
1352
  if (liveArrays.has(offset)) return;
991
1353
  liveArrays.add(offset);
1354
+ arrayBlockSizes.set(
1355
+ offset,
1356
+ Math.max(ARRAY_HEADER + capacity * SLOT_SIZE, MIN_BLOCK_SIZE),
1357
+ );
992
1358
  for (let i = 0; i < count; i++) {
993
1359
  markValueSlot(
994
1360
  readSlot(this.arrayTable, offset + ARRAY_HEADER + i * SLOT_SIZE),
@@ -1055,19 +1421,10 @@ export class SharedMemory {
1055
1421
  const sortedArrays = [...liveArrays].sort((a, b) => a - b);
1056
1422
  let newArrayOffset = 0;
1057
1423
  for (const oldOffset of sortedArrays) {
1058
- // Determine the array's capacity by scanning forward to the next live
1059
- // array or the end of the used region.
1060
- const next = sortedArrays[sortedArrays.indexOf(oldOffset) + 1];
1061
- const end = next !== undefined ? next : this.arrayTable.byteLength;
1062
- const capacity = (end - oldOffset - ARRAY_HEADER) / SLOT_SIZE;
1063
- copyBytes(
1064
- this.arrayTable,
1065
- newArrayOffset,
1066
- oldOffset,
1067
- ARRAY_HEADER + capacity * SLOT_SIZE,
1068
- );
1424
+ const size = arrayBlockSizes.get(oldOffset)!;
1425
+ copyBytes(this.arrayTable, newArrayOffset, oldOffset, size);
1069
1426
  arrayRemap.set(oldOffset, newArrayOffset);
1070
- newArrayOffset += ARRAY_HEADER + capacity * SLOT_SIZE;
1427
+ newArrayOffset += size;
1071
1428
  }
1072
1429
  this.arrayTable.reset(newArrayOffset);
1073
1430
 
@@ -1113,10 +1470,10 @@ export class SharedMemory {
1113
1470
  remapSlot(this.dataTable, newOff, stringRemap, arrayRemap);
1114
1471
  }
1115
1472
  for (const oldOffset of sortedArrays) {
1473
+ if (stringValueBlocks.has(oldOffset)) continue;
1116
1474
  const newOff = arrayRemap.get(oldOffset)!;
1117
- const next = sortedArrays[sortedArrays.indexOf(oldOffset) + 1];
1118
- const end = next !== undefined ? next : this.arrayTable.byteLength;
1119
- const capacity = (end - oldOffset - ARRAY_HEADER) / SLOT_SIZE;
1475
+ const capacity =
1476
+ (arrayBlockSizes.get(oldOffset)! - ARRAY_HEADER) / SLOT_SIZE;
1120
1477
  for (let i = 0; i < capacity; i++) {
1121
1478
  remapSlot(
1122
1479
  this.arrayTable,
@@ -1172,7 +1529,7 @@ function copyBytes(
1172
1529
  /**
1173
1530
  * Rewrite a value slot's string/array references after compaction, in place.
1174
1531
  * String refs and bigint-str refs are remapped via `stringRemap`; array refs
1175
- * via `arrayRemap`.
1532
+ * and string-value refs (which live in the ArrayTable) via `arrayRemap`.
1176
1533
  */
1177
1534
  function remapSlot(
1178
1535
  buf: GrowableBuffer,
@@ -1183,7 +1540,7 @@ function remapSlot(
1183
1540
  const type = buf.getInt32(offset);
1184
1541
  if (type === TYPE_STRING_REF || type === TYPE_BIGINT_STR) {
1185
1542
  buf.setInt32(offset + 4, stringRemap.get(buf.getInt32(offset + 4))!);
1186
- } else if (type === TYPE_ARRAY_REF) {
1543
+ } else if (type === TYPE_ARRAY_REF || type === TYPE_STRING_VALUE_REF) {
1187
1544
  buf.setInt32(offset + 4, arrayRemap.get(buf.getInt32(offset + 4))!);
1188
1545
  }
1189
1546
  }