bun-shared-memory 0.1.7 → 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/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:
@@ -211,6 +226,17 @@ export interface SharedMemoryOptions {
211
226
  layoutVersion?: SharedArrayBuffer;
212
227
  refCount?: SharedArrayBuffer;
213
228
  budget?: SharedArrayBuffer;
229
+ cursors?: {
230
+ indexTable?: SharedArrayBuffer;
231
+ dataTable?: SharedArrayBuffer;
232
+ stringTable?: SharedArrayBuffer;
233
+ arrayTable?: SharedArrayBuffer;
234
+ };
235
+ freeLists?: {
236
+ dataTable?: SharedArrayBuffer;
237
+ stringTable?: SharedArrayBuffer;
238
+ arrayTable?: SharedArrayBuffer;
239
+ };
214
240
  };
215
241
  }
216
242
 
@@ -223,6 +249,8 @@ function makeBuffer(
223
249
  chunkSize?: number,
224
250
  singleBuffer = false,
225
251
  chargeInitial = true,
252
+ cursorBuffer?: SharedArrayBuffer,
253
+ freeListBuffer?: SharedArrayBuffer,
226
254
  ): GrowableBuffer {
227
255
  if (buf === undefined) {
228
256
  return new GrowableBuffer(
@@ -232,6 +260,8 @@ function makeBuffer(
232
260
  chunkSize,
233
261
  singleBuffer,
234
262
  chargeInitial,
263
+ cursorBuffer,
264
+ freeListBuffer,
235
265
  );
236
266
  }
237
267
  return new GrowableBuffer(
@@ -241,6 +271,8 @@ function makeBuffer(
241
271
  chunkSize,
242
272
  singleBuffer,
243
273
  chargeInitial,
274
+ cursorBuffer,
275
+ freeListBuffer,
244
276
  );
245
277
  }
246
278
 
@@ -265,7 +297,7 @@ export class SharedMemory {
265
297
  * on growth. The version is incremented on every write so workers can
266
298
  * invalidate their read caches cross-worker.
267
299
  */
268
- private versionView: Int32Array;
300
+ versionView: Int32Array;
269
301
  /**
270
302
  * Global layout version cell. Bumped by the main thread after a
271
303
  * defragmentation pass so workers can invalidate cached offsets (indexCache,
@@ -359,6 +391,8 @@ export class SharedMemory {
359
391
  undefined,
360
392
  chunkSize,
361
393
  true,
394
+ true,
395
+ buffers?.cursors?.indexTable,
362
396
  );
363
397
  this.dataTable = makeBuffer(
364
398
  buffers?.dataTable,
@@ -368,6 +402,8 @@ export class SharedMemory {
368
402
  chunkSize,
369
403
  false,
370
404
  chargeInitial,
405
+ buffers?.cursors?.dataTable,
406
+ buffers?.freeLists?.dataTable,
371
407
  );
372
408
  this.stringTable = makeBuffer(
373
409
  buffers?.stringTable,
@@ -377,6 +413,8 @@ export class SharedMemory {
377
413
  chunkSize,
378
414
  false,
379
415
  chargeInitial,
416
+ buffers?.cursors?.stringTable,
417
+ buffers?.freeLists?.stringTable,
380
418
  );
381
419
  this.arrayTable = makeBuffer(
382
420
  buffers?.arrayTable,
@@ -386,11 +424,13 @@ export class SharedMemory {
386
424
  chunkSize,
387
425
  false,
388
426
  chargeInitial,
427
+ buffers?.cursors?.arrayTable,
428
+ buffers?.freeLists?.arrayTable,
389
429
  );
390
430
  this.versionView = new Int32Array(this.indexTable.buffers[0]!);
391
431
  this.layoutVersion = new Int32Array(
392
432
  buffers?.layoutVersion ??
393
- new SharedArrayBuffer(Int32Array.BYTES_PER_ELEMENT),
433
+ new SharedArrayBuffer(Int32Array.BYTES_PER_ELEMENT * 3),
394
434
  );
395
435
  this.lock = options?.lock ?? new Mutex();
396
436
  this.refCountBuffer = buffers?.refCount ?? null;
@@ -453,6 +493,17 @@ export class SharedMemory {
453
493
  indexTableMaxByteLength: number;
454
494
  maxTotalBytes: number | null;
455
495
  budget: SharedArrayBuffer | null;
496
+ cursors: {
497
+ indexTable: SharedArrayBuffer;
498
+ dataTable: SharedArrayBuffer;
499
+ stringTable: SharedArrayBuffer;
500
+ arrayTable: SharedArrayBuffer;
501
+ };
502
+ freeLists: {
503
+ dataTable: SharedArrayBuffer;
504
+ stringTable: SharedArrayBuffer;
505
+ arrayTable: SharedArrayBuffer;
506
+ };
456
507
  } {
457
508
  return {
458
509
  indexTable: this.indexTable.buffers,
@@ -466,6 +517,17 @@ export class SharedMemory {
466
517
  indexTableMaxByteLength: this.indexTableMaxByteLength,
467
518
  maxTotalBytes: this.maxTotalBytes,
468
519
  budget: this.budgetBuffer,
520
+ cursors: {
521
+ indexTable: this.indexTable.cursorBuffer,
522
+ dataTable: this.dataTable.cursorBuffer,
523
+ stringTable: this.stringTable.cursorBuffer,
524
+ arrayTable: this.arrayTable.cursorBuffer,
525
+ },
526
+ freeLists: {
527
+ dataTable: this.dataTable.freeListBuffer,
528
+ stringTable: this.stringTable.freeListBuffer,
529
+ arrayTable: this.arrayTable.freeListBuffer,
530
+ },
469
531
  };
470
532
  }
471
533
 
@@ -485,6 +547,17 @@ export class SharedMemory {
485
547
  indexTableMaxByteLength?: number;
486
548
  maxTotalBytes?: number | null;
487
549
  budget?: SharedArrayBuffer | null;
550
+ cursors?: {
551
+ indexTable?: SharedArrayBuffer;
552
+ dataTable?: SharedArrayBuffer;
553
+ stringTable?: SharedArrayBuffer;
554
+ arrayTable?: SharedArrayBuffer;
555
+ };
556
+ freeLists?: {
557
+ dataTable?: SharedArrayBuffer;
558
+ stringTable?: SharedArrayBuffer;
559
+ arrayTable?: SharedArrayBuffer;
560
+ };
488
561
  }): SharedMemory {
489
562
  const indexBuffer = Array.isArray(buffers.indexTable)
490
563
  ? buffers.indexTable[0]
@@ -505,6 +578,8 @@ export class SharedMemory {
505
578
  layoutVersion: buffers.layoutVersion,
506
579
  refCount: buffers.refCount ?? undefined,
507
580
  budget: buffers.budget ?? undefined,
581
+ cursors: buffers.cursors,
582
+ freeLists: buffers.freeLists,
508
583
  },
509
584
  lock: new Mutex(buffers.lock),
510
585
  });
@@ -541,10 +616,8 @@ export class SharedMemory {
541
616
  }
542
617
 
543
618
  const bytes = this.encoder.encode(value);
544
- const offset = this.stringTable.byteLength;
545
- this.stringTable.ensureCapacity(offset + bytes.byteLength);
619
+ const offset = this.stringTable.reserve(bytes.byteLength);
546
620
  this.stringTable.writeBytes(offset, bytes);
547
- this.stringTable.advance(bytes.byteLength);
548
621
  const ref: StringRef = { offset, length: bytes.byteLength };
549
622
 
550
623
  // Insert into the cache, growing capacity up to `internMax` before evicting.
@@ -582,6 +655,86 @@ export class SharedMemory {
582
655
  return this.decoder.decode(bytes);
583
656
  }
584
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
+
585
738
  // ---------------------------------------------------------------------------
586
739
  // ArrayTable
587
740
  // ---------------------------------------------------------------------------
@@ -598,8 +751,8 @@ export class SharedMemory {
598
751
  storeArray(slots: ValueSlot[], capacity?: number): ArrayRef {
599
752
  const count = slots.length;
600
753
  const cap = Math.max(capacity ?? 0, count, 1);
601
- const offset = this.arrayTable.byteLength;
602
- this.arrayTable.ensureCapacity(offset + ARRAY_HEADER + cap * SLOT_SIZE);
754
+ const size = ARRAY_HEADER + cap * SLOT_SIZE;
755
+ const offset = this.arrayTable.reserve(size);
603
756
  const objectId = this.nextObjectId++;
604
757
  this.arrayTable.setInt32(offset, objectId);
605
758
  for (let i = 0; i < count; i++) {
@@ -609,7 +762,6 @@ export class SharedMemory {
609
762
  slots[i]!,
610
763
  );
611
764
  }
612
- this.arrayTable.advance(ARRAY_HEADER + cap * SLOT_SIZE);
613
765
  return { offset, count, capacity: cap };
614
766
  }
615
767
 
@@ -633,6 +785,63 @@ export class SharedMemory {
633
785
  );
634
786
  }
635
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 a short string inline into an array slot (up to 11
817
+ * UTF-8 bytes), avoiding a heap allocation and the string-table write.
818
+ * Returns true if the string fit inline; false if it must go to the heap.
819
+ */
820
+ updateArrayInlineStringSlot(
821
+ ref: ArrayRef,
822
+ index: number,
823
+ value: string,
824
+ ): boolean {
825
+ const bytes = this.encoder.encode(value);
826
+ if (bytes.byteLength > 11) return false;
827
+ const buf = this.arrayTable;
828
+ const off = ref.offset + ARRAY_HEADER + index * SLOT_SIZE;
829
+ buf.setInt32(off, TYPE_INLINE_STRING);
830
+ let a = 0;
831
+ let b = 0;
832
+ let c = bytes.byteLength << 28;
833
+ for (let i = 0; i < bytes.byteLength; i++) {
834
+ const byte = bytes[i]!;
835
+ if (i < 4) a |= byte << (i * 8);
836
+ else if (i < 8) b |= byte << ((i - 4) * 8);
837
+ else c |= byte << ((i - 8) * 8);
838
+ }
839
+ buf.setInt32(off + 4, a);
840
+ buf.setInt32(off + 8, b);
841
+ buf.setInt32(off + 12, c);
842
+ return true;
843
+ }
844
+
636
845
  /** Read a single value slot of an array from the ArrayTable. */
637
846
  readArraySlot(ref: ArrayRef, index: number): ValueSlot {
638
847
  return readSlot(
@@ -652,10 +861,8 @@ export class SharedMemory {
652
861
  * the DataTable to point at the new location.
653
862
  */
654
863
  moveArray(ref: ArrayRef, newCapacity: number): ArrayRef {
655
- const newOffset = this.arrayTable.byteLength;
656
- this.arrayTable.ensureCapacity(
657
- newOffset + ARRAY_HEADER + newCapacity * SLOT_SIZE,
658
- );
864
+ const size = ARRAY_HEADER + newCapacity * SLOT_SIZE;
865
+ const newOffset = this.arrayTable.reserve(size);
659
866
  const objectId = this.getArrayObjectId(ref.offset);
660
867
  this.arrayTable.setInt32(newOffset, objectId);
661
868
  for (let i = 0; i < ref.count; i++) {
@@ -665,7 +872,13 @@ export class SharedMemory {
665
872
  readSlot(this.arrayTable, ref.offset + ARRAY_HEADER + i * SLOT_SIZE),
666
873
  );
667
874
  }
668
- this.arrayTable.advance(ARRAY_HEADER + newCapacity * SLOT_SIZE);
875
+ // The old block is no longer referenced; it is reclaimed by defrag.
876
+ this.freeArray(ref);
877
+ // The array moved to a new offset, so any cached ArrayRef (e.g. the
878
+ // per-target `__arrayRef` on the write hot path) is now stale. Bump the
879
+ // global layout version so those caches are invalidated, exactly as a
880
+ // defragmentation pass does.
881
+ this.bumpLayoutVersion();
669
882
  return { offset: newOffset, count: ref.count, capacity: newCapacity };
670
883
  }
671
884
 
@@ -701,16 +914,23 @@ export class SharedMemory {
701
914
  };
702
915
  }
703
916
 
917
+ /**
918
+ * Free an array block, pushing it onto the ArrayTable free list so it can
919
+ * be reused. Detects double frees. The block's data is overwritten with
920
+ * free-list metadata, so it must not be read after freeing.
921
+ */
922
+ freeArray(_ref: ArrayRef): void {
923
+ // No-op: array blocks are reclaimed by defrag, not the free list.
924
+ }
925
+
704
926
  // ---------------------------------------------------------------------------
705
927
  // DataTable
706
928
  // ---------------------------------------------------------------------------
707
929
 
708
930
  /** Store a single value slot in the DataTable and return its offset. */
709
931
  storeValueSlot(slot: ValueSlot): number {
710
- const offset = this.dataTable.byteLength;
711
- this.dataTable.ensureCapacity(offset + SLOT_SIZE);
932
+ const offset = this.dataTable.reserve(SLOT_SIZE);
712
933
  writeSlot(this.dataTable, offset, slot);
713
- this.dataTable.advance(SLOT_SIZE);
714
934
  return offset;
715
935
  }
716
936
 
@@ -724,6 +944,62 @@ export class SharedMemory {
724
944
  writeSlot(this.dataTable, offset, slot);
725
945
  }
726
946
 
947
+ /**
948
+ * Fast-path: write a float64 number slot directly into the DataTable,
949
+ * avoiding the ValueSlot object allocation and the 4 separate Int32
950
+ * writes of the generic path.
951
+ */
952
+ updateNumberSlot(offset: number, value: number): void {
953
+ slotView.setFloat64(0, value, true);
954
+ const buf = this.dataTable;
955
+ buf.setInt32(offset, TYPE_NUMBER);
956
+ buf.setInt32(offset + 4, slotView.getInt32(0, true));
957
+ buf.setInt32(offset + 8, slotView.getInt32(4, true));
958
+ buf.setInt32(offset + 12, 0);
959
+ }
960
+
961
+ /**
962
+ * Fast-path: write a boolean slot directly into the DataTable, avoiding
963
+ * the ValueSlot object allocation of the generic path.
964
+ */
965
+ updateBooleanSlot(offset: number, value: boolean): void {
966
+ const buf = this.dataTable;
967
+ buf.setInt32(offset, TYPE_BOOLEAN);
968
+ buf.setInt32(offset + 4, value ? 1 : 0);
969
+ buf.setInt32(offset + 8, 0);
970
+ buf.setInt32(offset + 12, 0);
971
+ }
972
+
973
+ /**
974
+ * Fast-path: write a short string inline into the slot itself (up to 11
975
+ * UTF-8 bytes), avoiding a heap allocation and the string-table write.
976
+ * The bytes are packed into the slot's a/b/c fields (11 bytes) with the
977
+ * length in the top 4 bits of `c`, so they can be read back without
978
+ * touching the DataTable again.
979
+ * Returns true if the string fit inline; false if it must go to the heap.
980
+ */
981
+ updateInlineStringSlot(offset: number, value: string): boolean {
982
+ const bytes = this.encoder.encode(value);
983
+ if (bytes.byteLength > 11) return false;
984
+ const buf = this.dataTable;
985
+ buf.setInt32(offset, TYPE_INLINE_STRING);
986
+ // Pack length into the top 4 bits of `c`; bytes 0-3 in `a`, 4-7 in `b`,
987
+ // 8-10 in the low 28 bits of `c`.
988
+ let a = 0;
989
+ let b = 0;
990
+ let c = bytes.byteLength << 28;
991
+ for (let i = 0; i < bytes.byteLength; i++) {
992
+ const byte = bytes[i]!;
993
+ if (i < 4) a |= byte << (i * 8);
994
+ else if (i < 8) b |= byte << ((i - 4) * 8);
995
+ else c |= byte << ((i - 8) * 8);
996
+ }
997
+ buf.setInt32(offset + 4, a);
998
+ buf.setInt32(offset + 8, b);
999
+ buf.setInt32(offset + 12, c);
1000
+ return true;
1001
+ }
1002
+
727
1003
  /**
728
1004
  * Create an object in the DataTable.
729
1005
  *
@@ -733,8 +1009,7 @@ export class SharedMemory {
733
1009
  createObject(properties: Array<[string, ValueSlot]>): number {
734
1010
  const blockSize =
735
1011
  OBJECT_HEADER + properties.length * (NAME_REF_SIZE + SLOT_SIZE);
736
- const offset = this.dataTable.byteLength;
737
- this.dataTable.ensureCapacity(offset + blockSize);
1012
+ const offset = this.dataTable.reserve(blockSize);
738
1013
 
739
1014
  const objectId = this.nextObjectId++;
740
1015
  this.dataTable.setInt32(offset, TYPE_OBJECT);
@@ -750,7 +1025,6 @@ export class SharedMemory {
750
1025
  writeSlot(this.dataTable, cursor, slot);
751
1026
  cursor += SLOT_SIZE;
752
1027
  }
753
- this.dataTable.advance(blockSize);
754
1028
  return offset;
755
1029
  }
756
1030
 
@@ -776,6 +1050,32 @@ export class SharedMemory {
776
1050
  return this.dataTable.getInt32(offset + 8);
777
1051
  }
778
1052
 
1053
+ /**
1054
+ * Free a single value slot in the DataTable, pushing it onto the free list
1055
+ * so it can be reused. Detects double frees.
1056
+ */
1057
+ freeValueSlot(_offset: number): void {
1058
+ // No-op: value slots are reclaimed by defrag, not the free list.
1059
+ }
1060
+
1061
+ /**
1062
+ * Free an object block in the DataTable, pushing it onto the free list so
1063
+ * it can be reused. Detects double frees. The block's data is overwritten
1064
+ * with free-list metadata, so it must not be read after freeing.
1065
+ */
1066
+ freeObject(_offset: number): void {
1067
+ // No-op: object blocks are reclaimed by defrag, not the free list.
1068
+ }
1069
+
1070
+ /**
1071
+ * Free a string in the StringTable, pushing it onto the free list so it can
1072
+ * be reused. Detects double frees. The string's bytes are overwritten with
1073
+ * free-list metadata, so it must not be read after freeing.
1074
+ */
1075
+ freeString(_ref: StringRef): void {
1076
+ // No-op: string blocks are reclaimed by defrag, not the free list.
1077
+ }
1078
+
779
1079
  /**
780
1080
  * Get the stable layout of an object value: its total DataTable offset and
781
1081
  * the slot offset of each property.
@@ -862,6 +1162,15 @@ export class SharedMemory {
862
1162
  return null;
863
1163
  }
864
1164
 
1165
+ /**
1166
+ * Int32Array index of a key's version cell within the Index Table buffer.
1167
+ * Stable for the lifetime of the key (the Index Table is never reordered or
1168
+ * replaced), so callers may cache it and load the version directly.
1169
+ */
1170
+ getVersionCellIndex(index: number): number {
1171
+ return versionCellIndex(index);
1172
+ }
1173
+
865
1174
  /** Read the current version for a key index (lock-free). */
866
1175
  getVersion(index: number): number {
867
1176
  return Atomics.load(this.versionView, versionCellIndex(index));
@@ -875,7 +1184,13 @@ export class SharedMemory {
875
1184
  /** Insert a key → dataOffset mapping into the Index Table. */
876
1185
  insert(key: string, dataOffset: number): void {
877
1186
  const keyRef = this.storeString(key);
878
- const count = this.indexTable.getInt32(0);
1187
+ // Atomically reserve the next entry index. The count cell (index 0) is
1188
+ // incremented with a single Atomics.add, so concurrent workers each get a
1189
+ // distinct entry slot without a mutex. The Index Table is a single
1190
+ // growable SAB, so a fresh Int32Array view over its current buffer is
1191
+ // correct (insert is rare — only on first write of a key).
1192
+ const view = new Int32Array(this.indexTable.buffers[0]!);
1193
+ const count = Atomics.add(view, 0, 1);
879
1194
  const entryOffset = INDEX_HEADER + count * INDEX_ENTRY_SIZE;
880
1195
  this.indexTable.ensureCapacity(entryOffset + INDEX_ENTRY_SIZE);
881
1196
  this.indexTable.setInt32(entryOffset, keyRef.offset);
@@ -883,8 +1198,6 @@ export class SharedMemory {
883
1198
  this.indexTable.setInt32(entryOffset + 8, dataOffset);
884
1199
  // The 4th field is the key's version cell, initialized to 0.
885
1200
  this.indexTable.setInt32(entryOffset + INDEX_VERSION_OFFSET, 0);
886
- this.indexTable.setInt32(0, count + 1);
887
- this.indexTable.advance(INDEX_ENTRY_SIZE);
888
1201
 
889
1202
  // Rebuild the version view over the first chunk (the Index Table is small
890
1203
  // and stays within the first chunk for typical workloads; version cells
@@ -906,6 +1219,49 @@ export class SharedMemory {
906
1219
  Atomics.add(this.layoutVersion, 0, 1);
907
1220
  }
908
1221
 
1222
+ // ---------------------------------------------------------------------------
1223
+ // Lock-free write coordination (write counter + defrag flag)
1224
+ // ---------------------------------------------------------------------------
1225
+ // Cell 0 of `layoutVersion` is the global layout version. Cells 1 and 2 are
1226
+ // used to coordinate lock-free writes against the (rare) defragmentation
1227
+ // pass: cell 1 is a count of in-flight lock-free writes, cell 2 is a flag
1228
+ // set while a defragmentation pass is running. A write increments the
1229
+ // counter, checks the flag, and if the flag is set it falls back to the
1230
+ // mutex. The defrag sets the flag, waits for the counter to reach zero, then
1231
+ // runs under the mutex.
1232
+
1233
+ /** Enter a lock-free write: increment the in-flight write counter. */
1234
+ beginWrite(): void {
1235
+ Atomics.add(this.layoutVersion, 1, 1);
1236
+ }
1237
+
1238
+ /** Leave a lock-free write: decrement the in-flight write counter. */
1239
+ endWrite(): void {
1240
+ Atomics.sub(this.layoutVersion, 1, 1);
1241
+ }
1242
+
1243
+ /** Whether a defragmentation pass is currently running. */
1244
+ isDefragmenting(): boolean {
1245
+ return Atomics.load(this.layoutVersion, 2) !== 0;
1246
+ }
1247
+
1248
+ /** Set the defragmentation-in-progress flag. */
1249
+ setDefragmenting(): void {
1250
+ Atomics.store(this.layoutVersion, 2, 1);
1251
+ }
1252
+
1253
+ /** Clear the defragmentation-in-progress flag. */
1254
+ clearDefragmenting(): void {
1255
+ Atomics.store(this.layoutVersion, 2, 0);
1256
+ }
1257
+
1258
+ /** Wait until no lock-free writes are in flight. */
1259
+ waitForWritesToFinish(): void {
1260
+ while (Atomics.load(this.layoutVersion, 1) !== 0) {
1261
+ Atomics.wait(this.layoutVersion, 1, Atomics.load(this.layoutVersion, 1));
1262
+ }
1263
+ }
1264
+
909
1265
  // ---------------------------------------------------------------------------
910
1266
  // Defragmentation
911
1267
  // ---------------------------------------------------------------------------
@@ -938,6 +1294,10 @@ export class SharedMemory {
938
1294
  const liveArrays = new Set<number>();
939
1295
  const liveObjects = new Set<number>();
940
1296
  const liveDataOffsets = new Set<number>();
1297
+ /** Byte size of each live ArrayTable block (array or string value). */
1298
+ const arrayBlockSizes = new Map<number, number>();
1299
+ /** Offsets of live string-value blocks (no slots to remap). */
1300
+ const stringValueBlocks = new Set<number>();
941
1301
 
942
1302
  const markString = (offset: number) => liveStrings.add(offset);
943
1303
 
@@ -947,15 +1307,27 @@ export class SharedMemory {
947
1307
  case TYPE_BIGINT_STR:
948
1308
  liveStrings.add(slot.a);
949
1309
  break;
1310
+ case TYPE_STRING_VALUE_REF:
1311
+ liveArrays.add(slot.a);
1312
+ stringValueBlocks.add(slot.a);
1313
+ arrayBlockSizes.set(
1314
+ slot.a,
1315
+ Math.max(ARRAY_HEADER + slot.b, MIN_BLOCK_SIZE),
1316
+ );
1317
+ break;
950
1318
  case TYPE_ARRAY_REF:
951
- markArray(slot.a, slot.b);
1319
+ markArray(slot.a, slot.b, slot.c);
952
1320
  break;
953
1321
  }
954
1322
  };
955
1323
 
956
- const markArray = (offset: number, count: number) => {
1324
+ const markArray = (offset: number, count: number, capacity: number) => {
957
1325
  if (liveArrays.has(offset)) return;
958
1326
  liveArrays.add(offset);
1327
+ arrayBlockSizes.set(
1328
+ offset,
1329
+ Math.max(ARRAY_HEADER + capacity * SLOT_SIZE, MIN_BLOCK_SIZE),
1330
+ );
959
1331
  for (let i = 0; i < count; i++) {
960
1332
  markValueSlot(
961
1333
  readSlot(this.arrayTable, offset + ARRAY_HEADER + i * SLOT_SIZE),
@@ -1022,19 +1394,10 @@ export class SharedMemory {
1022
1394
  const sortedArrays = [...liveArrays].sort((a, b) => a - b);
1023
1395
  let newArrayOffset = 0;
1024
1396
  for (const oldOffset of sortedArrays) {
1025
- // Determine the array's capacity by scanning forward to the next live
1026
- // array or the end of the used region.
1027
- const next = sortedArrays[sortedArrays.indexOf(oldOffset) + 1];
1028
- const end = next !== undefined ? next : this.arrayTable.byteLength;
1029
- const capacity = (end - oldOffset - ARRAY_HEADER) / SLOT_SIZE;
1030
- copyBytes(
1031
- this.arrayTable,
1032
- newArrayOffset,
1033
- oldOffset,
1034
- ARRAY_HEADER + capacity * SLOT_SIZE,
1035
- );
1397
+ const size = arrayBlockSizes.get(oldOffset)!;
1398
+ copyBytes(this.arrayTable, newArrayOffset, oldOffset, size);
1036
1399
  arrayRemap.set(oldOffset, newArrayOffset);
1037
- newArrayOffset += ARRAY_HEADER + capacity * SLOT_SIZE;
1400
+ newArrayOffset += size;
1038
1401
  }
1039
1402
  this.arrayTable.reset(newArrayOffset);
1040
1403
 
@@ -1080,10 +1443,10 @@ export class SharedMemory {
1080
1443
  remapSlot(this.dataTable, newOff, stringRemap, arrayRemap);
1081
1444
  }
1082
1445
  for (const oldOffset of sortedArrays) {
1446
+ if (stringValueBlocks.has(oldOffset)) continue;
1083
1447
  const newOff = arrayRemap.get(oldOffset)!;
1084
- const next = sortedArrays[sortedArrays.indexOf(oldOffset) + 1];
1085
- const end = next !== undefined ? next : this.arrayTable.byteLength;
1086
- const capacity = (end - oldOffset - ARRAY_HEADER) / SLOT_SIZE;
1448
+ const capacity =
1449
+ (arrayBlockSizes.get(oldOffset)! - ARRAY_HEADER) / SLOT_SIZE;
1087
1450
  for (let i = 0; i < capacity; i++) {
1088
1451
  remapSlot(
1089
1452
  this.arrayTable,
@@ -1139,7 +1502,7 @@ function copyBytes(
1139
1502
  /**
1140
1503
  * Rewrite a value slot's string/array references after compaction, in place.
1141
1504
  * String refs and bigint-str refs are remapped via `stringRemap`; array refs
1142
- * via `arrayRemap`.
1505
+ * and string-value refs (which live in the ArrayTable) via `arrayRemap`.
1143
1506
  */
1144
1507
  function remapSlot(
1145
1508
  buf: GrowableBuffer,
@@ -1150,7 +1513,7 @@ function remapSlot(
1150
1513
  const type = buf.getInt32(offset);
1151
1514
  if (type === TYPE_STRING_REF || type === TYPE_BIGINT_STR) {
1152
1515
  buf.setInt32(offset + 4, stringRemap.get(buf.getInt32(offset + 4))!);
1153
- } else if (type === TYPE_ARRAY_REF) {
1516
+ } else if (type === TYPE_ARRAY_REF || type === TYPE_STRING_VALUE_REF) {
1154
1517
  buf.setInt32(offset + 4, arrayRemap.get(buf.getInt32(offset + 4))!);
1155
1518
  }
1156
1519
  }