@volter/browser-wali 0.1.5 → 0.1.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (158) hide show
  1. package/README.md +73 -0
  2. package/dist/browser-wali-command.d.ts +3 -0
  3. package/dist/browser-wali-command.d.ts.map +1 -1
  4. package/dist/browser-wali-command.js +2 -1
  5. package/dist/browser-wali-command.js.map +1 -1
  6. package/dist/capabilities.d.ts +73 -1
  7. package/dist/capabilities.d.ts.map +1 -1
  8. package/dist/capabilities.js +86 -2
  9. package/dist/capabilities.js.map +1 -1
  10. package/dist/child-processes.d.ts +24 -0
  11. package/dist/child-processes.d.ts.map +1 -0
  12. package/dist/child-processes.js +134 -0
  13. package/dist/child-processes.js.map +1 -0
  14. package/dist/codec-protocol.d.ts +66 -0
  15. package/dist/codec-protocol.d.ts.map +1 -0
  16. package/dist/codec-protocol.js +59 -0
  17. package/dist/codec-protocol.js.map +1 -0
  18. package/dist/codec-remote.d.ts +33 -0
  19. package/dist/codec-remote.d.ts.map +1 -0
  20. package/dist/codec-remote.js +190 -0
  21. package/dist/codec-remote.js.map +1 -0
  22. package/dist/codec-worker.d.ts +2 -0
  23. package/dist/codec-worker.d.ts.map +1 -0
  24. package/dist/codec-worker.js +438 -0
  25. package/dist/codec-worker.js.map +1 -0
  26. package/dist/coherent-file-locks.d.ts.map +1 -1
  27. package/dist/coherent-file-locks.js +17 -5
  28. package/dist/coherent-file-locks.js.map +1 -1
  29. package/dist/descriptors.d.ts +49 -0
  30. package/dist/descriptors.d.ts.map +1 -0
  31. package/dist/descriptors.js +115 -0
  32. package/dist/descriptors.js.map +1 -0
  33. package/dist/events.d.ts.map +1 -1
  34. package/dist/events.js +2 -2
  35. package/dist/events.js.map +1 -1
  36. package/dist/filesystem-protocol.d.ts +28 -1
  37. package/dist/filesystem-protocol.d.ts.map +1 -1
  38. package/dist/filesystem-protocol.js +18 -0
  39. package/dist/filesystem-protocol.js.map +1 -1
  40. package/dist/filesystem-remote.d.ts +26 -2
  41. package/dist/filesystem-remote.d.ts.map +1 -1
  42. package/dist/filesystem-remote.js +132 -23
  43. package/dist/filesystem-remote.js.map +1 -1
  44. package/dist/filesystem-worker.js +274 -34
  45. package/dist/filesystem-worker.js.map +1 -1
  46. package/dist/filesystem.d.ts +30 -1
  47. package/dist/filesystem.d.ts.map +1 -1
  48. package/dist/filesystem.js +180 -50
  49. package/dist/filesystem.js.map +1 -1
  50. package/dist/fork-worker.d.ts +2 -0
  51. package/dist/fork-worker.d.ts.map +1 -0
  52. package/dist/fork-worker.js +52 -0
  53. package/dist/fork-worker.js.map +1 -0
  54. package/dist/host.d.ts +36 -0
  55. package/dist/host.d.ts.map +1 -0
  56. package/dist/host.js +153 -0
  57. package/dist/host.js.map +1 -0
  58. package/dist/loopback-host.d.ts +25 -1
  59. package/dist/loopback-host.d.ts.map +1 -1
  60. package/dist/loopback-host.js +447 -13
  61. package/dist/loopback-host.js.map +1 -1
  62. package/dist/memory.d.ts +65 -2
  63. package/dist/memory.d.ts.map +1 -1
  64. package/dist/memory.js +306 -49
  65. package/dist/memory.js.map +1 -1
  66. package/dist/module-layout.d.ts +2 -0
  67. package/dist/module-layout.d.ts.map +1 -1
  68. package/dist/module-layout.js +4 -2
  69. package/dist/module-layout.js.map +1 -1
  70. package/dist/monotonic-clock.d.ts +12 -0
  71. package/dist/monotonic-clock.d.ts.map +1 -0
  72. package/dist/monotonic-clock.js +14 -0
  73. package/dist/monotonic-clock.js.map +1 -0
  74. package/dist/network-protocol.d.ts +12 -1
  75. package/dist/network-protocol.d.ts.map +1 -1
  76. package/dist/network-protocol.js +6 -0
  77. package/dist/network-protocol.js.map +1 -1
  78. package/dist/network-worker.js +149 -20
  79. package/dist/network-worker.js.map +1 -1
  80. package/dist/network.d.ts +15 -3
  81. package/dist/network.d.ts.map +1 -1
  82. package/dist/network.js +74 -39
  83. package/dist/network.js.map +1 -1
  84. package/dist/process-adapter.d.ts +4 -1
  85. package/dist/process-adapter.d.ts.map +1 -1
  86. package/dist/process-adapter.js +3 -3
  87. package/dist/process-adapter.js.map +1 -1
  88. package/dist/process-bridge.d.ts +43 -0
  89. package/dist/process-bridge.d.ts.map +1 -1
  90. package/dist/process-bridge.js +30 -1
  91. package/dist/process-bridge.js.map +1 -1
  92. package/dist/program.d.ts +60 -1
  93. package/dist/program.d.ts.map +1 -1
  94. package/dist/program.js +92 -31
  95. package/dist/program.js.map +1 -1
  96. package/dist/runtime.d.ts.map +1 -1
  97. package/dist/runtime.js +491 -134
  98. package/dist/runtime.js.map +1 -1
  99. package/dist/select.d.ts +16 -0
  100. package/dist/select.d.ts.map +1 -0
  101. package/dist/select.js +100 -0
  102. package/dist/select.js.map +1 -0
  103. package/dist/shared-pipes.d.ts +17 -6
  104. package/dist/shared-pipes.d.ts.map +1 -1
  105. package/dist/shared-pipes.js +94 -70
  106. package/dist/shared-pipes.js.map +1 -1
  107. package/dist/signals.d.ts +48 -0
  108. package/dist/signals.d.ts.map +1 -0
  109. package/dist/signals.js +232 -0
  110. package/dist/signals.js.map +1 -0
  111. package/dist/snapshot-filesystem.d.ts +128 -2
  112. package/dist/snapshot-filesystem.d.ts.map +1 -1
  113. package/dist/snapshot-filesystem.js +481 -53
  114. package/dist/snapshot-filesystem.js.map +1 -1
  115. package/dist/spawn-policy.d.ts +5 -0
  116. package/dist/spawn-policy.d.ts.map +1 -0
  117. package/dist/spawn-policy.js +29 -0
  118. package/dist/spawn-policy.js.map +1 -0
  119. package/dist/stdin.d.ts +2 -0
  120. package/dist/stdin.d.ts.map +1 -1
  121. package/dist/stdin.js +4 -0
  122. package/dist/stdin.js.map +1 -1
  123. package/dist/symlink.d.ts +8 -0
  124. package/dist/symlink.d.ts.map +1 -1
  125. package/dist/symlink.js +13 -0
  126. package/dist/symlink.js.map +1 -1
  127. package/dist/system.d.ts +152 -1
  128. package/dist/system.d.ts.map +1 -1
  129. package/dist/system.js +226 -7
  130. package/dist/system.js.map +1 -1
  131. package/dist/thread-coordinator-worker.d.ts +1 -0
  132. package/dist/thread-coordinator-worker.d.ts.map +1 -1
  133. package/dist/thread-coordinator-worker.js +1 -0
  134. package/dist/thread-coordinator-worker.js.map +1 -1
  135. package/dist/thread-worker.js +241 -122
  136. package/dist/thread-worker.js.map +1 -1
  137. package/dist/threads.d.ts +12 -2
  138. package/dist/threads.d.ts.map +1 -1
  139. package/dist/threads.js +30 -10
  140. package/dist/threads.js.map +1 -1
  141. package/dist/types.d.ts +39 -0
  142. package/dist/types.d.ts.map +1 -1
  143. package/dist/websocket-peer.d.ts.map +1 -1
  144. package/dist/websocket-peer.js +4 -14
  145. package/dist/websocket-peer.js.map +1 -1
  146. package/dist/worker-program-protocol.d.ts +70 -3
  147. package/dist/worker-program-protocol.d.ts.map +1 -1
  148. package/dist/worker-program-worker.js +115 -23
  149. package/dist/worker-program-worker.js.map +1 -1
  150. package/dist/worker-program.d.ts +25 -1
  151. package/dist/worker-program.d.ts.map +1 -1
  152. package/dist/worker-program.js +470 -61
  153. package/dist/worker-program.js.map +1 -1
  154. package/dist/worker-standby.d.ts +3 -0
  155. package/dist/worker-standby.d.ts.map +1 -0
  156. package/dist/worker-standby.js +17 -0
  157. package/dist/worker-standby.js.map +1 -0
  158. package/package.json +20 -6
package/dist/memory.js CHANGED
@@ -13,8 +13,34 @@ const DEFAULT_MMAP_BASE = 256 * 1024 * 1024;
13
13
  const MMAP_CURSOR_INDEX = 0;
14
14
  const PROGRAM_BREAK_INDEX = 1;
15
15
  const ALLOCATOR_LOCK_INDEX = 2;
16
- const ALLOCATOR_HEADER_WORDS = 3;
17
- const FREE_RANGE_SLOTS = 512;
16
+ // The address-space instrument. These words exist because the ways this
17
+ // allocator could lose address space were all SILENT: a release that no
18
+ // free-range slot could hold was dropped with a bare `return`, a munmap from a
19
+ // thread that did not map the range was retained forever, and the mmap cursor
20
+ // was stored as a 32-bit word, so a reservation past the 4 GiB ceiling wrapped
21
+ // to a low address instead of refusing. Each is counted here and the ones that
22
+ // still can happen are surfaced through the runtime's `onDiagnostic`; the
23
+ // numbers are what a reader gets instead of "3 GB free" over a refused 8 MiB.
24
+ const DROPPED_RELEASES_INDEX = 3;
25
+ const DROPPED_KIB_INDEX = 4;
26
+ const CURSOR_HIGH_WATER_KIB_INDEX = 5;
27
+ const FREE_RANGE_PEAK_INDEX = 6;
28
+ const RETAINED_MUNMAPS_INDEX = 7;
29
+ const RETAINED_KIB_INDEX = 8;
30
+ const CEILING_REFUSALS_INDEX = 9;
31
+ const FREE_RANGE_COUNT_INDEX = 10;
32
+ const TOUCHED_HIGH_WATER_INDEX = 11;
33
+ const ALLOCATOR_HEADER_WORDS = 12;
34
+ // The free-range table is DENSE: entries [0, count) are live, a removed entry
35
+ // is swapped with the last, and every scan is over the ranges that exist, not
36
+ // over the capacity. MEASURED 2026-09-19 on the courtyard model alone: a
37
+ // 512-slot sparse table filled before the model's tenth call, 8,192 released
38
+ // ranges were then dropped (693 MiB of address space lost by the 1 GiB mark),
39
+ // the cursor walked to 4,095 MiB and wrapped. mallocng maps every group and
40
+ // every large chunk on its own and unmaps them out of order, so the live
41
+ // free-range count is a property of the guest's heap, not of this table.
42
+ const FREE_RANGE_SLOTS = 16_384;
43
+ const RESERVE_REFUSED = -1;
18
44
  const FREE_RANGE_WORDS = 2;
19
45
  const ALLOCATOR_CONTROL_WORDS = ALLOCATOR_HEADER_WORDS + FREE_RANGE_SLOTS * FREE_RANGE_WORDS;
20
46
  const SHARED_MMAP_REGISTRY_SLOTS = 256;
@@ -40,14 +66,16 @@ export class WaliMemoryManager {
40
66
  readFile;
41
67
  fileKey;
42
68
  writeSharedFile;
69
+ report;
43
70
  mappings = [];
44
71
  allocatorState;
45
72
  allocatorInitialized = false;
46
- constructor(memory, readFile, sharedMmapCursor, fileKey, writeSharedFile, sharedMmapRegistry) {
73
+ constructor(memory, readFile, sharedMmapCursor, fileKey, writeSharedFile, sharedMmapRegistry, report) {
47
74
  this.memory = memory;
48
75
  this.readFile = readFile;
49
76
  this.fileKey = fileKey;
50
77
  this.writeSharedFile = writeSharedFile;
78
+ this.report = report;
51
79
  const control = sharedMmapCursor ?? createWaliAllocatorControl();
52
80
  this.allocatorState = new Int32Array(control);
53
81
  if (this.allocatorState.length < ALLOCATOR_CONTROL_WORDS) {
@@ -64,6 +92,15 @@ export class WaliMemoryManager {
64
92
  this.initializeAllocator();
65
93
  return this.allocatorState.buffer;
66
94
  }
95
+ forkSnapshot() {
96
+ return { mappings: this.mappings.map(mapping => ({ ...mapping })),
97
+ ...(this.sharedRegistry ? { registry: this.sharedRegistry.buffer.slice(0) } : {}) };
98
+ }
99
+ restoreFork(snapshot) {
100
+ this.mappings.push(...snapshot.mappings.map(mapping => ({ ...mapping })));
101
+ if (snapshot.registry && this.sharedRegistry)
102
+ this.sharedRegistry.set(new Int32Array(snapshot.registry));
103
+ }
67
104
  imports() {
68
105
  return {
69
106
  SYS_brk: (address) => this.brk(Number(address)),
@@ -82,9 +119,12 @@ export class WaliMemoryManager {
82
119
  return BigInt(current);
83
120
  if (!validAddress(requested))
84
121
  return BigInt(current);
85
- const mmapCursor = Atomics.load(this.allocatorState, MMAP_CURSOR_INDEX);
86
- const firstMapping = mmapCursor === 0 ? DEFAULT_MMAP_BASE : mmapCursor;
87
- if (requested > firstMapping)
122
+ // The break grows below the mmap region and never into it. The region's
123
+ // base is fixed at DEFAULT_MMAP_BASE (or the initial memory size when that
124
+ // is larger, in which case no brk succeeds and mallocng maps its meta
125
+ // areas instead); comparing against the moving cursor let the break climb
126
+ // over live mappings.
127
+ if (requested > DEFAULT_MMAP_BASE)
88
128
  return BigInt(current);
89
129
  if (!this.ensureCapacity(requested))
90
130
  return BigInt(current);
@@ -98,9 +138,21 @@ export class WaliMemoryManager {
98
138
  const anonymous = Boolean(flags & MAP_ANONYMOUS);
99
139
  if ((anonymous && fd !== -1) || (!anonymous && fd < 0))
100
140
  return -ENODEV;
101
- const fileContents = anonymous ? undefined : this.readFile(fd, offset, length);
102
- if (!anonymous && !fileContents)
103
- return -9n;
141
+ let fileContents;
142
+ if (!anonymous) {
143
+ try {
144
+ fileContents = this.readFile(fd, offset, length);
145
+ }
146
+ catch (error) {
147
+ // A kernel answers a failed file mapping with an errno; this runtime
148
+ // used to die here (an EMSGSIZE from the filesystem channel on a
149
+ // large file was a fatal for the whole program, 2026-09-19).
150
+ this.say(`[WALI] mmap of fd ${fd}, ${length} bytes at ${offset}, could not read the file: ${error instanceof Error ? error.message : String(error)}`);
151
+ return -EIO;
152
+ }
153
+ if (!fileContents)
154
+ return -9n;
155
+ }
104
156
  const size = align(length, LINUX_PAGE_SIZE);
105
157
  const fixed = Boolean(flags & MAP_FIXED);
106
158
  const shared = Boolean(flags & MAP_SHARED);
@@ -121,6 +173,8 @@ export class WaliMemoryManager {
121
173
  }
122
174
  }
123
175
  const start = fixed ? requested : registryClaim?.start ?? this.reserve(size);
176
+ if (start === RESERVE_REFUSED)
177
+ return -ENOMEM;
124
178
  if (!validAddress(start) || (fixed && start % LINUX_PAGE_SIZE !== 0))
125
179
  return -EINVAL;
126
180
  const end = start + size;
@@ -135,7 +189,7 @@ export class WaliMemoryManager {
135
189
  ...(registryClaim ? { registrySlot: registryClaim.slot } : {}),
136
190
  });
137
191
  if (!registryClaim?.existing) {
138
- new Uint8Array(this.memory().buffer).fill(0, start, end);
192
+ this.zeroForHandout(start, end);
139
193
  if (fileContents)
140
194
  new Uint8Array(this.memory().buffer).set(fileContents, start);
141
195
  }
@@ -159,14 +213,24 @@ export class WaliMemoryManager {
159
213
  const index = this.mappings.findIndex((candidate) => address >= candidate.start && end <= candidate.end);
160
214
  const mapping = index >= 0 ? this.mappings[index] : undefined;
161
215
  // Mapping metadata is local to a syscall host, but a Unix process may
162
- // mmap on one pthread and munmap on another. Releasing an unknown range
163
- // into the process-wide free list lets a later mmap overlap its still-live
164
- // owner and corrupts allocators such as musl malloc and SQLite. Retain the
165
- // address conservatively; the process's WebAssembly memory is discarded
166
- // in full when the command exits.
167
- if (!mapping)
216
+ // mmap on one pthread and munmap on another: mallocng frees a >=128 KiB
217
+ // chunk on whichever thread calls free(), and unmaps it there. The
218
+ // address space is process-wide (`allocatorState`), so the range goes back
219
+ // to the process's free list the way a kernel would take it. The one
220
+ // thing another thread's record could carry that this one cannot see is a
221
+ // shared FILE mapping's flush, and those live in the shared registry, so a
222
+ // range a live registry entry covers is retained (counted, surfaced) rather
223
+ // than released under it. Retaining every unknown range instead leaked the
224
+ // address space of every cross-thread free until the 4 GiB ceiling.
225
+ if (!mapping) {
226
+ if (this.sharedRegistryCovers(address, end)) {
227
+ this.noteRetainedMunmap(end - address);
228
+ return 0n;
229
+ }
230
+ this.release(address, end);
168
231
  return 0n;
169
- if (mapping?.registrySlot !== undefined) {
232
+ }
233
+ if (mapping.registrySlot !== undefined) {
170
234
  if (address !== mapping.start || end !== mapping.end)
171
235
  return -EINVAL;
172
236
  if (!this.flushMapping(mapping, address, end))
@@ -175,17 +239,98 @@ export class WaliMemoryManager {
175
239
  this.mappings.splice(index, 1);
176
240
  return 0n;
177
241
  }
178
- if (mapping?.shared && mapping.references > 1) {
242
+ if (mapping.shared && mapping.references > 1) {
179
243
  mapping.references -= 1;
180
244
  return 0n;
181
245
  }
182
- if (mapping?.shared && !this.flushMapping(mapping, address, end))
246
+ if (mapping.shared && !this.flushMapping(mapping, address, end))
183
247
  return -EIO;
184
- this.mappings.splice(index, 1);
185
- new Uint8Array(this.memory().buffer).fill(0, address, Math.min(end, this.memory().buffer.byteLength));
248
+ // A partial unmap keeps the remainder on record, so a later unmap of the
249
+ // rest is still this thread's own mapping and not an unknown range.
250
+ if (address === mapping.start && end === mapping.end) {
251
+ this.mappings.splice(index, 1);
252
+ }
253
+ else if (address === mapping.start) {
254
+ mapping.fileOffset += end - mapping.start;
255
+ mapping.start = end;
256
+ }
257
+ else if (end === mapping.end) {
258
+ mapping.end = address;
259
+ }
260
+ else {
261
+ this.mappings.push({ ...mapping, start: end, fileOffset: mapping.fileOffset + (end - mapping.start) });
262
+ mapping.end = address;
263
+ }
264
+ // No zeroing here: the next mmap of these bytes zeroes them (`zeroForHandout`),
265
+ // and zeroing on both sides doubled the cost of every large free.
186
266
  this.release(address, end);
187
267
  return 0n;
188
268
  }
269
+ /**
270
+ * Zero a range about to be handed out, and only the part of it that has
271
+ * been handed out before. Memory the wasm module grows into is already
272
+ * zero, so the bytes above the touched high-water mark need nothing; a
273
+ * reused range below it holds whatever its last owner left.
274
+ * MEASURED 2026-09-19: malloc+free of an UNTOUCHED 64 MiB took 40 ms on
275
+ * WALI against 4 ms on Emscripten, with the copy and the memset themselves
276
+ * at 2.3 and 0.9 ms -- the whole cost was two byte-view fills of shared
277
+ * memory, one here and one on munmap. V8 fills a SharedArrayBuffer through
278
+ * Uint8Array 15x slower than a plain buffer (20.9 ms vs 1.4 ms per 64 MiB,
279
+ * node 26); through Float64Array it is 2.5 ms, so the aligned body is
280
+ * filled 8 bytes at a time.
281
+ */
282
+ zeroForHandout(start, end) {
283
+ end = Math.min(end, this.memory().buffer.byteLength);
284
+ if (end <= start)
285
+ return;
286
+ let touched = Atomics.load(this.allocatorState, TOUCHED_HIGH_WATER_INDEX) >>> 0;
287
+ if (start < touched)
288
+ this.zero(start, Math.min(end, touched));
289
+ while (end > touched) {
290
+ const seen = Atomics.compareExchange(this.allocatorState, TOUCHED_HIGH_WATER_INDEX, touched | 0, end | 0) >>> 0;
291
+ if (seen === touched)
292
+ break;
293
+ touched = seen;
294
+ }
295
+ }
296
+ zero(start, end) {
297
+ const buffer = this.memory().buffer;
298
+ const bytes = new Uint8Array(buffer);
299
+ const bodyStart = (start + 7) & ~7;
300
+ const bodyEnd = end & ~7;
301
+ if (bodyEnd <= bodyStart) {
302
+ bytes.fill(0, start, end);
303
+ return;
304
+ }
305
+ if (bodyStart > start)
306
+ bytes.fill(0, start, bodyStart);
307
+ new Float64Array(buffer, bodyStart, (bodyEnd - bodyStart) / 8).fill(0);
308
+ if (end > bodyEnd)
309
+ bytes.fill(0, bodyEnd, end);
310
+ }
311
+ sharedRegistryCovers(start, end) {
312
+ const registry = this.sharedRegistry;
313
+ if (!registry)
314
+ return false;
315
+ this.lockSharedRegistry();
316
+ try {
317
+ for (let slot = 0; slot < SHARED_MMAP_REGISTRY_SLOTS; slot += 1) {
318
+ const base = registryBase(slot);
319
+ if (Atomics.load(registry, base + REGISTRY_STATE) === 0)
320
+ continue;
321
+ if ((Atomics.load(registry, base + REGISTRY_REFERENCES) >>> 0) === 0)
322
+ continue;
323
+ const mappedStart = Atomics.load(registry, base + REGISTRY_START) >>> 0;
324
+ const mappedEnd = mappedStart + (Atomics.load(registry, base + REGISTRY_SIZE) >>> 0);
325
+ if (start < mappedEnd && end > mappedStart)
326
+ return true;
327
+ }
328
+ return false;
329
+ }
330
+ finally {
331
+ this.unlockSharedRegistry();
332
+ }
333
+ }
189
334
  mremap(address, oldLength, newLength, flags, requested) {
190
335
  if (!validPageRange(address, oldLength) || !Number.isSafeInteger(newLength) || newLength <= 0)
191
336
  return -EINVAL;
@@ -202,7 +347,6 @@ export class WaliMemoryManager {
202
347
  if (oldEnd > this.memory().buffer.byteLength)
203
348
  return -ENOMEM;
204
349
  if (newSize <= oldSize) {
205
- new Uint8Array(this.memory().buffer).fill(0, address + newSize, address + oldSize);
206
350
  if (mapping)
207
351
  mapping.end = address + newSize;
208
352
  this.release(address + newSize, oldEnd);
@@ -216,7 +360,7 @@ export class WaliMemoryManager {
216
360
  const canReserveTail = this.reserveTail(oldEnd, expandedEnd);
217
361
  const canExpand = canReserveTail && this.ensureCapacity(expandedEnd);
218
362
  if (canExpand) {
219
- new Uint8Array(this.memory().buffer).fill(0, address + oldSize, expandedEnd);
363
+ this.zeroForHandout(address + oldSize, expandedEnd);
220
364
  if (mapping)
221
365
  mapping.end = expandedEnd;
222
366
  return BigInt(address);
@@ -228,6 +372,8 @@ export class WaliMemoryManager {
228
372
  const target = flags & MREMAP_FIXED
229
373
  ? requested
230
374
  : this.reserve(newSize);
375
+ if (target === RESERVE_REFUSED)
376
+ return -ENOMEM;
231
377
  const targetEnd = target + newSize;
232
378
  if (!validAddress(target) || target % LINUX_PAGE_SIZE !== 0 || this.overlaps(target, targetEnd, mapping))
233
379
  return -EINVAL;
@@ -237,8 +383,7 @@ export class WaliMemoryManager {
237
383
  return -ENOMEM;
238
384
  }
239
385
  const contents = new Uint8Array(this.memory().buffer).slice(address, address + Math.min(oldSize, newSize));
240
- new Uint8Array(this.memory().buffer).fill(0, address, address + oldSize);
241
- new Uint8Array(this.memory().buffer).fill(0, target, targetEnd);
386
+ this.zeroForHandout(target, targetEnd);
242
387
  new Uint8Array(this.memory().buffer).set(contents, target);
243
388
  this.release(address, oldEnd);
244
389
  if (mapping) {
@@ -307,6 +452,8 @@ export class WaliMemoryManager {
307
452
  if (freeSlot < 0)
308
453
  return;
309
454
  const start = this.reserve(size);
455
+ if (start === RESERVE_REFUSED)
456
+ return;
310
457
  const base = registryBase(freeSlot);
311
458
  Atomics.store(registry, base + REGISTRY_HASH_A, hashA | 0);
312
459
  Atomics.store(registry, base + REGISTRY_HASH_B, hashB | 0);
@@ -348,15 +495,15 @@ export class WaliMemoryManager {
348
495
  reserve(size) {
349
496
  this.lockAllocator();
350
497
  try {
351
- for (let slot = 0; slot < FREE_RANGE_SLOTS; slot += 1) {
498
+ const count = this.freeRangeCount();
499
+ for (let slot = 0; slot < count; slot += 1) {
352
500
  const base = freeRangeBase(slot);
353
501
  const start = Atomics.load(this.allocatorState, base) >>> 0;
354
502
  const available = Atomics.load(this.allocatorState, base + 1) >>> 0;
355
503
  if (available < size)
356
504
  continue;
357
505
  if (available === size) {
358
- Atomics.store(this.allocatorState, base, 0);
359
- Atomics.store(this.allocatorState, base + 1, 0);
506
+ this.removeFreeRange(slot);
360
507
  }
361
508
  else {
362
509
  Atomics.store(this.allocatorState, base, (start + size) | 0);
@@ -368,7 +515,16 @@ export class WaliMemoryManager {
368
515
  if (cursor === 0) {
369
516
  cursor = align(Math.max(this.memory().buffer.byteLength, DEFAULT_MMAP_BASE), LINUX_PAGE_SIZE);
370
517
  }
518
+ // wasm32 addresses end at 0xffff_ffff. Storing `cursor + size` past it
519
+ // wrapped the cursor to a low address (the store is a 32-bit word), and
520
+ // the next mapping was handed out over the data segment. Refuse instead;
521
+ // mmap turns this into ENOMEM, which the guest already handles.
522
+ if (cursor + size > 0xffff_ffff) {
523
+ this.noteCeiling(cursor, size);
524
+ return RESERVE_REFUSED;
525
+ }
371
526
  Atomics.store(this.allocatorState, MMAP_CURSOR_INDEX, (cursor + size) | 0);
527
+ this.noteCursor(cursor + size);
372
528
  return cursor;
373
529
  }
374
530
  finally {
@@ -390,19 +546,16 @@ export class WaliMemoryManager {
390
546
  let mergedEnd = end;
391
547
  for (;;) {
392
548
  let merged = false;
393
- for (let slot = 0; slot < FREE_RANGE_SLOTS; slot += 1) {
549
+ for (let slot = 0; slot < this.freeRangeCount(); slot += 1) {
394
550
  const base = freeRangeBase(slot);
395
551
  const rangeStart = Atomics.load(this.allocatorState, base) >>> 0;
396
- const rangeSize = Atomics.load(this.allocatorState, base + 1) >>> 0;
397
- if (rangeSize === 0)
398
- continue;
399
- const rangeEnd = rangeStart + rangeSize;
552
+ const rangeEnd = rangeStart + (Atomics.load(this.allocatorState, base + 1) >>> 0);
400
553
  if (rangeEnd < mergedStart || rangeStart > mergedEnd)
401
554
  continue;
402
555
  mergedStart = Math.min(mergedStart, rangeStart);
403
556
  mergedEnd = Math.max(mergedEnd, rangeEnd);
404
- Atomics.store(this.allocatorState, base, 0);
405
- Atomics.store(this.allocatorState, base + 1, 0);
557
+ this.removeFreeRange(slot);
558
+ slot -= 1;
406
559
  merged = true;
407
560
  }
408
561
  if (!merged)
@@ -414,12 +567,34 @@ export class WaliMemoryManager {
414
567
  this.collapseFreeTail();
415
568
  }
416
569
  else if (mergedEnd <= cursor) {
417
- const freeSlot = this.firstFreeRangeSlot();
418
- if (freeSlot < 0)
570
+ const count = this.freeRangeCount();
571
+ if (count < FREE_RANGE_SLOTS) {
572
+ this.storeFreeRange(count, mergedStart, mergedEnd - mergedStart);
573
+ Atomics.store(this.allocatorState, FREE_RANGE_COUNT_INDEX, count + 1);
574
+ this.noteFreeRangeOccupancy(count + 1);
419
575
  return;
420
- const base = freeRangeBase(freeSlot);
421
- Atomics.store(this.allocatorState, base, mergedStart | 0);
422
- Atomics.store(this.allocatorState, base + 1, (mergedEnd - mergedStart) | 0);
576
+ }
577
+ // The table is full. Keep the larger ranges: the smallest entry gives
578
+ // way to a bigger release, and whichever is lost is counted and said.
579
+ let smallest = 0;
580
+ let smallestSize = Infinity;
581
+ for (let slot = 0; slot < count; slot += 1) {
582
+ const size = Atomics.load(this.allocatorState, freeRangeBase(slot) + 1) >>> 0;
583
+ if (size < smallestSize) {
584
+ smallest = slot;
585
+ smallestSize = size;
586
+ }
587
+ }
588
+ if (smallestSize < mergedEnd - mergedStart) {
589
+ this.storeFreeRange(smallest, mergedStart, mergedEnd - mergedStart);
590
+ this.noteDroppedRelease(smallestSize);
591
+ }
592
+ else {
593
+ this.noteDroppedRelease(mergedEnd - mergedStart);
594
+ }
595
+ }
596
+ else {
597
+ this.noteDroppedRelease(mergedEnd - mergedStart);
423
598
  }
424
599
  }
425
600
  finally {
@@ -427,6 +602,8 @@ export class WaliMemoryManager {
427
602
  }
428
603
  }
429
604
  reserveTail(start, end) {
605
+ if (end > 0xffff_ffff)
606
+ return false;
430
607
  this.lockAllocator();
431
608
  try {
432
609
  if ((Atomics.load(this.allocatorState, MMAP_CURSOR_INDEX) >>> 0) !== start)
@@ -438,25 +615,105 @@ export class WaliMemoryManager {
438
615
  this.unlockAllocator();
439
616
  }
440
617
  }
441
- firstFreeRangeSlot() {
442
- for (let slot = 0; slot < FREE_RANGE_SLOTS; slot += 1) {
443
- if ((Atomics.load(this.allocatorState, freeRangeBase(slot) + 1) >>> 0) === 0)
444
- return slot;
618
+ /**
619
+ * THE ADDRESS-SPACE INSTRUMENT. Read this before changing `release()` or
620
+ * `reserve()`: both of them could lose address space without saying a word,
621
+ * and that silence is what made the second Blender model in a session die at
622
+ * an 8 MiB numpy array while every headroom probe reported gigabytes free.
623
+ * A probe that allocates until refused COALESCES into a few large ranges and
624
+ * never fills the slot table; the pattern that fills it is many small
625
+ * non-adjacent mappings freed out of order, which is what mallocng does with
626
+ * every group it maps and every large chunk (the courtyard's free ranges
627
+ * averaged 80 KiB).
628
+ */
629
+ noteDroppedRelease(bytes) {
630
+ const dropped = Atomics.add(this.allocatorState, DROPPED_RELEASES_INDEX, 1) + 1;
631
+ const kib = Atomics.add(this.allocatorState, DROPPED_KIB_INDEX, Math.round(bytes / 1024)) + Math.round(bytes / 1024);
632
+ // One line per power of two: the first drop is the news, and the hundredth
633
+ // copy of a notice buries the first (the same rule the -ENOSYS and thread
634
+ // pool notices follow in runtime.ts).
635
+ if ((dropped & (dropped - 1)) !== 0)
636
+ return;
637
+ this.say(`[WALI] the mmap free-range table is full (${FREE_RANGE_SLOTS} slots):`
638
+ + ` ${dropped} released range${dropped === 1 ? "" : "s"} could not be recorded,`
639
+ + ` ${(kib / 1024).toFixed(1)} MiB of address space unreusable.`);
640
+ }
641
+ noteCursor(cursor) {
642
+ const kib = Math.round(cursor / 1024);
643
+ const previous = Atomics.load(this.allocatorState, CURSOR_HIGH_WATER_KIB_INDEX) >>> 0;
644
+ if (kib > previous)
645
+ Atomics.store(this.allocatorState, CURSOR_HIGH_WATER_KIB_INDEX, kib | 0);
646
+ }
647
+ noteRetainedMunmap(bytes) {
648
+ const count = Atomics.add(this.allocatorState, RETAINED_MUNMAPS_INDEX, 1) + 1;
649
+ const kib = Atomics.add(this.allocatorState, RETAINED_KIB_INDEX, Math.round(bytes / 1024)) + Math.round(bytes / 1024);
650
+ if ((count & (count - 1)) !== 0)
651
+ return;
652
+ this.say(`[WALI] munmap of a range this thread did not map: ${count} retained,`
653
+ + ` ${(kib / 1024).toFixed(1)} MiB of address space never released.`);
654
+ }
655
+ noteCeiling(cursor, size) {
656
+ const count = Atomics.add(this.allocatorState, CEILING_REFUSALS_INDEX, 1) + 1;
657
+ this.say(`[WALI] mmap cursor ${(cursor / 1048576).toFixed(0)} MiB + ${(size / 1048576).toFixed(2)} MiB`
658
+ + ` passes the 4096 MiB wasm32 ceiling (${count}).`);
659
+ }
660
+ say(notice) {
661
+ const line = `${notice} ${this.stateSuffix()}`;
662
+ console.error(line);
663
+ this.report?.(line);
664
+ }
665
+ stateSuffix() {
666
+ const occupied = this.freeRangeCount();
667
+ let free = 0;
668
+ for (let slot = 0; slot < occupied; slot += 1)
669
+ free += Atomics.load(this.allocatorState, freeRangeBase(slot) + 1) >>> 0;
670
+ const cursor = Atomics.load(this.allocatorState, MMAP_CURSOR_INDEX) >>> 0;
671
+ return `{cursor ${(cursor / 1048576).toFixed(0)} MiB, memory ${(this.memory().buffer.byteLength / 1048576).toFixed(0)} MiB,`
672
+ + ` free table ${occupied}/${FREE_RANGE_SLOTS} ranges holding ${(free / 1048576).toFixed(1)} MiB}`;
673
+ }
674
+ noteFreeRangeOccupancy(occupied) {
675
+ if (occupied > (Atomics.load(this.allocatorState, FREE_RANGE_PEAK_INDEX) >>> 0)) {
676
+ Atomics.store(this.allocatorState, FREE_RANGE_PEAK_INDEX, occupied | 0);
677
+ }
678
+ }
679
+ /** The instrument's totals, for a host that wants them at the end of a run. */
680
+ addressSpaceReport() {
681
+ return {
682
+ droppedReleases: Atomics.load(this.allocatorState, DROPPED_RELEASES_INDEX) >>> 0,
683
+ droppedKiB: Atomics.load(this.allocatorState, DROPPED_KIB_INDEX) >>> 0,
684
+ cursorHighWaterKiB: Atomics.load(this.allocatorState, CURSOR_HIGH_WATER_KIB_INDEX) >>> 0,
685
+ freeRangePeak: Atomics.load(this.allocatorState, FREE_RANGE_PEAK_INDEX) >>> 0,
686
+ };
687
+ }
688
+ freeRangeCount() {
689
+ return Atomics.load(this.allocatorState, FREE_RANGE_COUNT_INDEX);
690
+ }
691
+ storeFreeRange(slot, start, size) {
692
+ const base = freeRangeBase(slot);
693
+ Atomics.store(this.allocatorState, base, start | 0);
694
+ Atomics.store(this.allocatorState, base + 1, size | 0);
695
+ }
696
+ /** Swap-remove: the last live entry takes the vacated slot. Caller holds the allocator lock. */
697
+ removeFreeRange(slot) {
698
+ const last = this.freeRangeCount() - 1;
699
+ if (slot !== last) {
700
+ const from = freeRangeBase(last);
701
+ this.storeFreeRange(slot, Atomics.load(this.allocatorState, from) >>> 0, Atomics.load(this.allocatorState, from + 1) >>> 0);
445
702
  }
446
- return -1;
703
+ this.storeFreeRange(last, 0, 0);
704
+ Atomics.store(this.allocatorState, FREE_RANGE_COUNT_INDEX, last);
447
705
  }
448
706
  collapseFreeTail() {
449
707
  for (;;) {
450
708
  const cursor = Atomics.load(this.allocatorState, MMAP_CURSOR_INDEX) >>> 0;
451
709
  let collapsed = false;
452
- for (let slot = 0; slot < FREE_RANGE_SLOTS; slot += 1) {
710
+ for (let slot = 0; slot < this.freeRangeCount(); slot += 1) {
453
711
  const base = freeRangeBase(slot);
454
712
  const start = Atomics.load(this.allocatorState, base) >>> 0;
455
713
  const size = Atomics.load(this.allocatorState, base + 1) >>> 0;
456
- if (size === 0 || start + size !== cursor)
714
+ if (start + size !== cursor)
457
715
  continue;
458
- Atomics.store(this.allocatorState, base, 0);
459
- Atomics.store(this.allocatorState, base + 1, 0);
716
+ this.removeFreeRange(slot);
460
717
  Atomics.store(this.allocatorState, MMAP_CURSOR_INDEX, start | 0);
461
718
  collapsed = true;
462
719
  break;