@daugasauron/dolly 0.1.4 → 0.1.5-rc.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.
Files changed (60) hide show
  1. package/README.md +134 -14
  2. package/coi-serviceworker.js +28 -0
  3. package/dist/dolly-build-id.mjs +1 -1
  4. package/dist/dolly-dso-0.wasm +0 -0
  5. package/dist/dolly-image-build-id.mjs +1 -1
  6. package/dist/dolly-process-0.wasm +0 -0
  7. package/dist/dolly-process-abi.mjs +1 -1
  8. package/dist/dolly-process-dso.mjs +382 -53
  9. package/dist/dolly-process-gate-0.wasm +0 -0
  10. package/dist/dolly-process-worker.mjs +164 -35
  11. package/dist/dolly.wasm +0 -0
  12. package/host/dso/abi.mjs +3 -1
  13. package/host/dso/dso.mjs +1 -2
  14. package/host/http/abi.mjs +2 -2
  15. package/host/http/broker.mjs +18 -8
  16. package/host/http/http.mjs +0 -1
  17. package/host/http/policy.mjs +8 -4
  18. package/host/input/abi.mjs +47 -0
  19. package/host/modules.mjs +4 -3
  20. package/host/nena/abi.mjs +1 -1
  21. package/host/nena/nena.mjs +16 -18
  22. package/host/packages/service.mjs +35 -15
  23. package/host/packages/worker.mjs +21 -0
  24. package/host/runtime/abi.mjs +1 -0
  25. package/host/runtime/boot-files.mjs +8 -2
  26. package/host/runtime/grower.mjs +11 -8
  27. package/host/runtime/lease-bridge.mjs +8 -5
  28. package/host/runtime/module.json +1 -1
  29. package/host/runtime/runtime.mjs +37 -18
  30. package/host/snapshot/abi.mjs +7 -9
  31. package/host/snapshot/snapshot.mjs +14 -12
  32. package/host/snapshot/transport.mjs +4 -12
  33. package/host/snapshot/ui.mjs +17 -7
  34. package/host/stack/stack.mjs +1 -2
  35. package/host/threads/abi.mjs +1 -1
  36. package/host/threads/threads.mjs +2 -1
  37. package/package.json +1 -1
  38. package/src/browser.mjs +34 -37
  39. package/src/build-log.mjs +13 -2
  40. package/src/build-worker.mjs +182 -0
  41. package/src/custom-image.mjs +59 -0
  42. package/src/dollyfile-graph.mjs +127 -0
  43. package/src/dollyfile-view.mjs +335 -0
  44. package/src/image-artifact.mjs +248 -0
  45. package/src/image-build.mjs +89 -0
  46. package/src/image-builder.mjs +63 -0
  47. package/src/images.mjs +13 -2
  48. package/src/pack-store.mjs +141 -0
  49. package/src/packaged-snapshot.mjs +263 -91
  50. package/src/page-chords.mjs +4 -2
  51. package/src/process-constants.mjs +4 -2
  52. package/src/process-supervisor.mjs +209 -122
  53. package/src/runtime-worker.mjs +9 -10
  54. package/src/session-store.mjs +5 -7
  55. package/src/sha256.mjs +65 -0
  56. package/src/snapshot-records.mjs +135 -23
  57. package/src/static-asset.mjs +29 -2
  58. package/src/terminal-text.mjs +8 -5
  59. package/src/version.mjs +1 -1
  60. package/src/worker-image.mjs +3 -5
@@ -13,6 +13,7 @@ import {
13
13
  DOLLY_PROCESS_SPAWN_INTERACTIVE,
14
14
  } from "./process-constants.mjs";
15
15
  import { hex } from "./static-asset.mjs";
16
+ import { DOLLY_FS_BLOCK_SIZE } from "../host/runtime/abi.mjs";
16
17
 
17
18
  const encoder = new TextEncoder();
18
19
  const spawnHeaderSize = DOLLY_PROCESS_SIZEOF.dolly_process_spawn_request;
@@ -26,23 +27,58 @@ const interruptedSystemCall = -BigInt(DOLLY_ERRNO.EINTR);
26
27
  // the memory of a 61st process of 8 GiB was refused.
27
28
  const processWorkerLimit = 56;
28
29
  const processMemoryBudgetBytes = 4 * 1024 * 1024 * 1024;
29
- // Chromium releases the address space of a terminated Worker's memory some
30
- // time after terminate(): a memory it cannot reserve is asked for again for
31
- // a while before the process is refused. A spawn the count refuses waits as
32
- // long, like any blocking call, asked again whenever a process ends and at
33
- // each service tick; then it fails EAGAIN, a full process table's answer. A
34
- // spawn the budget refuses waits until a process ends, however long: make and
35
- // Cargo end a build at a spawn's EAGAIN. So a pipeline started over the
36
- // budget waits forever if its first stage fills the pipe before the next
37
- // stage is admitted; a signal (Ctrl+C) ends the wait.
38
- const reservationRetryMilliseconds = 50;
30
+ // A spawn the count refuses waits as long as a Worker asks again for a memory
31
+ // Chromium cannot reserve yet (src/process-worker.mjs), like any blocking
32
+ // call, asked again whenever a process ends and at each service tick; then it
33
+ // fails EAGAIN, a full process table's answer. A spawn the budget refuses
34
+ // waits until a process ends, however long: make and Cargo end a build at a
35
+ // spawn's EAGAIN. So a pipeline started over the budget waits forever if its
36
+ // first stage fills the pipe before the next stage is admitted; a signal
37
+ // (Ctrl+C) ends the wait.
39
38
  const reservationPatienceMilliseconds = 5000;
39
+ // A Worker ended mid-copy into its mailbox finishes the copy within
40
+ // microseconds; one that holds the mailbox this long keeps it.
41
+ const mailboxSealMilliseconds = 1000;
40
42
  const interruptGraceMilliseconds = 500;
41
43
  const compiledModuleCacheEntries = 64;
42
44
  const compiledModuleCacheBytes = 256 * 1024 * 1024;
43
45
  // File versions known to hold a cached module's bytes (dolly_process_image_version).
44
46
  const compiledVersionEntries = 1024;
45
47
  const compilationNoticeMilliseconds = 250;
48
+ // A large executable's module is compiled and held in a Worker of its own,
49
+ // which goes once neither the cache nor a process holds the module: a Module
50
+ // held here would go only at this Worker's next collection, which compiled
51
+ // code does not hasten. Four 96 MB executables launched in turn kept five
52
+ // compiled in Firefox, where the cache held two. An idle Worker costs about
53
+ // 2.5 MB, so a small module is compiled and held here.
54
+ const heldModuleBytes = 16 * 1024 * 1024;
55
+ const holderUrl = URL.createObjectURL(new Blob([`let module;
56
+ onmessage = async ({ data: { bytes, port } }) => {
57
+ if (port) return port.postMessage(module);
58
+ try {
59
+ module = await WebAssembly.compile(bytes);
60
+ postMessage(null);
61
+ } catch (error) {
62
+ postMessage(String(error?.message ?? error));
63
+ } finally {
64
+ bytes.transfer(0);
65
+ }
66
+ };`], { type: "text/javascript" }));
67
+ // Each holder until it answers: Firefox ends a Worker that only its own
68
+ // handlers reach once its loop is idle, as after it posts its answer, and
69
+ // that answer is lost.
70
+ const compiling = new Set();
71
+ // The Worker holding the module compiled from BYTES, which it takes.
72
+ function compileHeld(bytes) {
73
+ const holder = new Worker(holderUrl, { name: "dolly-module" });
74
+ compiling.add(holder);
75
+ return new Promise((resolve, reject) => {
76
+ const fail = message => { holder.terminate(); reject(new Error(message)); };
77
+ holder.onmessage = ({ data }) => data === null ? resolve(holder) : fail(data);
78
+ holder.onerror = event => { event.preventDefault(); fail(event.message || "module Worker failed"); };
79
+ holder.postMessage({ bytes: bytes.buffer }, [bytes.buffer]);
80
+ }).finally(() => compiling.delete(holder));
81
+ }
46
82
  const largeInteractiveProcessBytes = 128 * 1024 * 1024;
47
83
  const workerReclamationMilliseconds = 500;
48
84
 
@@ -126,15 +162,13 @@ function encodeSpawn(arguments_, flags) {
126
162
  return packet;
127
163
  }
128
164
 
129
- function createProcessMemory({ initial, maximum }) {
130
- return new WebAssembly.Memory({
131
- initial,
132
- maximum,
133
- shared: true,
134
- address: "i64",
135
- });
136
- }
137
-
165
+ // A process's memory lives in its own Workers alone, never in this one: a
166
+ // shared memory's pages go when the last agent holding it drops it, and a
167
+ // living Worker drops one only when it collects garbage, which a small heap
168
+ // rarely needs (tasks/20261101-102515-retained-process-memory). So each
169
+ // Worker makes or receives the memory, holds the gate and copies its requests
170
+ // into a mailbox of its own in kernel memory, and the memory goes with the
171
+ // Worker. This Worker only names that mailbox to the kernel.
138
172
  export class DollyProcessSupervisor {
139
173
  constructor(kernel, kernelMemory, gateModule, workerUrl, processContract, hostAbi, serviceHost, threadContract, threadHost, processModules) {
140
174
  if (!(kernelMemory instanceof WebAssembly.Memory) ||
@@ -227,7 +261,7 @@ export class DollyProcessSupervisor {
227
261
  if (this.processes.size >= processWorkerLimit) return "count";
228
262
  let committed = 0, fanning = false;
229
263
  for (const process of this.processes.values()) {
230
- committed += process.memory?.buffer.byteLength ?? 0;
264
+ committed += process.memoryBytes;
231
265
  fanning ||= process.parent === parent && !process.retiring;
232
266
  }
233
267
  return committed < processMemoryBudgetBytes || !fanning ? null : "budget";
@@ -260,8 +294,8 @@ export class DollyProcessSupervisor {
260
294
  throw new Error(`kernel supplied invalid process ${pid}`);
261
295
  }
262
296
  const process = {
263
- pid, parent, resolve, reject, worker: null, gate: null, control: null,
264
- memory: null, messageHandler: null, errorHandler: null,
297
+ pid, parent, resolve, reject, worker: null, control: null, mailbox: 0,
298
+ memoryBytes: 0, messageHandler: null, errorHandler: null,
265
299
  messageErrorHandler: null, started: false,
266
300
  failure: null, interactive: (flags & DOLLY_PROCESS_SPAWN_INTERACTIVE) !== 0, retiring: false,
267
301
  interruptTimer: null, deadlineTimer: null, retirementTimer: null, lastSignal: 0,
@@ -317,15 +351,18 @@ export class DollyProcessSupervisor {
317
351
  }
318
352
 
319
353
  #deliverSignal(process, signalNumber = DOLLY_PROCESS_SIGINT) {
320
- if (!process || process.retiring || this.processes.get(process.pid) !== process) return false;
354
+ if (!process || process.retiring || this.processes.get(process.pid) !== process) return;
321
355
  const result = this.kernel.dolly_process_signal(process.pid, signalNumber);
322
356
  process.lastSignal = signalNumber;
323
- // Resize notification is not an interrupt request and must never acquire
324
- // a forced-termination deadline, including before program entry.
325
- if (signalNumber === DOLLY_PROCESS_SIGWINCH && (result !== 0 || !process.started)) return result === 0;
326
- if (signalNumber === DOLLY_PROCESS_SIGKILL || result !== 0 || !process.started) {
327
- return this.#forceExit(process.pid, 128 + signalNumber, signalNumber);
357
+ // The kernel decides what the signal does: it may have ended the process.
358
+ if (this.kernel.dolly_process_exited(process.pid) !== 0) {
359
+ this.#retire(process);
360
+ return;
328
361
  }
362
+ // A program not yet entered has no call to interrupt; what the kernel
363
+ // keeps for it, it takes when it starts. One that ignores the signal
364
+ // (result 1) has nothing to answer.
365
+ if (result !== 0 || !process.started) return;
329
366
  const receiver = process.threaded
330
367
  ? [...process.threads.values()].sort((a, b) => a.tid - b.tid)[0] : process;
331
368
  const deferred = this.deferred.get(receiver);
@@ -342,7 +379,6 @@ export class DollyProcessSupervisor {
342
379
  this.#signal(receiver, message.sequence, interruptedSystemCall);
343
380
  }
344
381
  if (signalNumber !== DOLLY_PROCESS_SIGWINCH) this.#armInterrupt(process, signalNumber);
345
- return true;
346
382
  }
347
383
 
348
384
  // A program that does not finish a delivered signal in time is ended by it.
@@ -395,15 +431,18 @@ export class DollyProcessSupervisor {
395
431
  }
396
432
 
397
433
  async #compileProcess(bytes) {
434
+ const byteLength = bytes.byteLength;
398
435
  const digest = hex(await crypto.subtle.digest("SHA-256", bytes));
399
- const key = `${bytes.byteLength}:${digest}`;
436
+ const key = `${byteLength}:${digest}`;
400
437
  if (this.compiledModules.has(key)) return this.#cachedModule(key);
438
+ // What this module will replace in the cache goes before it is compiled.
439
+ if (byteLength <= compiledModuleCacheBytes) this.#evict(byteLength);
401
440
 
402
441
  const started = performance.now();
403
442
  let noticeShown = false;
404
443
  const notice = setTimeout(() => {
405
444
  noticeShown = true;
406
- const mebibytes = (bytes.byteLength / (1024 * 1024)).toFixed(1);
445
+ const mebibytes = (byteLength / (1024 * 1024)).toFixed(1);
407
446
  this.#writeTerminal(
408
447
  `\r\ndolly: preparing ${mebibytes} MiB WebAssembly executable for this session...\r\n`,
409
448
  );
@@ -422,7 +461,7 @@ export class DollyProcessSupervisor {
422
461
  checkHostAbi(requirements, this.hostAbi);
423
462
  threaded = validateThreadProfile(parsed, requirements, this.threadContract);
424
463
  largeStack = parsed.customSections.includes("dolly.process.stack");
425
- module = await WebAssembly.compile(bytes);
464
+ module = byteLength >= heldModuleBytes ? await compileHeld(bytes) : await WebAssembly.compile(bytes);
426
465
  processInterface = { imports: parsed.imports, exports: parsed.exports,
427
466
  castEmulationParameters: parsed.castEmulationParameters };
428
467
  prepared = true;
@@ -436,21 +475,35 @@ export class DollyProcessSupervisor {
436
475
  );
437
476
  }
438
477
  }
439
- const compiled = { key, module, memoryRequirements, processInterface, threaded, largeStack, requirements, byteLength: bytes.byteLength };
440
- if (bytes.byteLength <= compiledModuleCacheBytes) {
478
+ const compiled = { key, module, memoryRequirements, processInterface, threaded, largeStack, requirements, byteLength };
479
+ if (byteLength <= compiledModuleCacheBytes) {
480
+ // An exec's compile may have run beside this one.
481
+ this.#evict(byteLength);
441
482
  this.compiledModules.set(key, compiled);
442
- this.compiledModuleBytes += bytes.byteLength;
443
- while (this.compiledModules.size > compiledModuleCacheEntries ||
444
- this.compiledModuleBytes > compiledModuleCacheBytes) {
445
- const oldestKey = this.compiledModules.keys().next().value;
446
- const oldest = this.compiledModules.get(oldestKey);
447
- this.compiledModules.delete(oldestKey);
448
- this.compiledModuleBytes -= oldest.byteLength;
449
- }
483
+ this.compiledModuleBytes += byteLength;
450
484
  }
451
485
  return compiled;
452
486
  }
453
487
 
488
+ // Evicts the least recently used modules until one of byteLength fits.
489
+ #evict(byteLength) {
490
+ while (this.compiledModules.size && (this.compiledModules.size >= compiledModuleCacheEntries ||
491
+ this.compiledModuleBytes + byteLength > compiledModuleCacheBytes)) {
492
+ const [oldestKey, oldest] = this.compiledModules.entries().next().value;
493
+ this.compiledModules.delete(oldestKey);
494
+ this.compiledModuleBytes -= oldest.byteLength;
495
+ this.#release(oldest);
496
+ }
497
+ }
498
+
499
+ // Ends the Worker of a held module (heldModuleBytes) that neither the cache
500
+ // nor a process holds.
501
+ #release(compiled) {
502
+ if (!(compiled.module instanceof Worker) || this.compiledModules.get(compiled.key) === compiled) return;
503
+ for (const process of this.processes.values()) if (process.compiled === compiled) return;
504
+ compiled.module.terminate();
505
+ }
506
+
454
507
  #writeTerminal(text) {
455
508
  const bytes = encoder.encode(text);
456
509
  const address = this.kernel.malloc(BigInt(Math.max(1, bytes.byteLength)));
@@ -470,42 +523,38 @@ export class DollyProcessSupervisor {
470
523
  async #launchPending() {
471
524
  for (let pid; (pid = this.kernel.dolly_process_next_launch()) !== 0;) {
472
525
  const process = this.processes.get(pid);
526
+ let compiled;
473
527
  try {
474
528
  if (!process) throw new Error(`kernel supplied unregistered process ${pid}`);
475
- const compiled = await this.#compiledImage(pid);
476
- if (!this.#canLaunch(process)) continue;
477
- const local = this.#admit(compiled);
478
- for (let waited = 0; ; waited += reservationRetryMilliseconds) {
479
- try {
480
- this.#start(process, compiled, local);
481
- break;
482
- } catch (error) {
483
- if (!(error instanceof RangeError) || process.memory || waited >= reservationPatienceMilliseconds) throw error;
484
- }
485
- await new Promise(resolve => setTimeout(resolve, reservationRetryMilliseconds));
486
- if (!this.#canLaunch(process)) break;
487
- }
529
+ compiled = await this.#compiledImage(pid);
530
+ if (this.#canLaunch(process)) this.#start(process, compiled, this.#admit(compiled));
488
531
  } catch (error) {
489
532
  // A failed launch ends only its own process; exiting it in the kernel
490
533
  // also stops next_launch from naming it again.
491
534
  if (process) this.#fail(process, error);
492
535
  else this.kernel.dolly_process_worker_exited(pid, 126, 0);
536
+ } finally {
537
+ if (compiled) this.#release(compiled);
493
538
  }
494
539
  }
495
540
  }
496
541
 
497
- // The bytes of the executable the kernel opened for a process, which it then closes.
542
+ // The bytes of the executable the kernel opened for a process, copied from
543
+ // the file's own blocks (the kernel makes no copy), which it then closes.
544
+ // One copy: Web Crypto rejects views of shared memory.
498
545
  #takeImage(pid) {
499
- const read = this.kernel.dolly_process_image_read(pid);
500
- if (read !== 0) throw new Error(`cannot read the executable of process ${pid}: ${read}`);
501
- const address = Number(this.kernel.dolly_process_image_address(pid));
502
546
  const size = Number(this.kernel.dolly_process_image_size(pid));
503
- if (!Number.isSafeInteger(address) || !Number.isSafeInteger(size) ||
504
- address <= 0 || size < 8 || address > this.kernelMemory.buffer.byteLength - size) {
505
- throw new Error(`kernel supplied an invalid executable for process ${pid}`);
506
- }
547
+ if (size < 0) throw new Error(`cannot read the executable of process ${pid}: ${size}`);
548
+ if (!Number.isSafeInteger(size) || size < 8) throw new Error(`kernel supplied an invalid executable for process ${pid}`);
507
549
  const bytes = new Uint8Array(size);
508
- bytes.set(new Uint8Array(this.kernelMemory.buffer, address, size));
550
+ for (let offset = 0; offset < size; offset += DOLLY_FS_BLOCK_SIZE) {
551
+ const length = Math.min(DOLLY_FS_BLOCK_SIZE, size - offset);
552
+ const address = Number(this.kernel.dolly_process_image_block(pid, BigInt(offset / DOLLY_FS_BLOCK_SIZE)));
553
+ if (!Number.isSafeInteger(address) || address <= 0 || address > this.kernelMemory.buffer.byteLength - length) {
554
+ throw new Error(`kernel supplied an invalid executable for process ${pid}`);
555
+ }
556
+ bytes.set(new Uint8Array(this.kernelMemory.buffer, address, length), offset);
557
+ }
509
558
  if (this.kernel.dolly_process_image_consumed(pid) !== 0) {
510
559
  throw new Error(`kernel did not release executable ${pid}`);
511
560
  }
@@ -513,26 +562,19 @@ export class DollyProcessSupervisor {
513
562
  }
514
563
 
515
564
  // Checked at every launch, not only when compiled: nena@0 may have disabled
516
- // a module since. The modules served in this executable's own Worker each
517
- // hold one Worker's state, such as its function table: one goes with threads
518
- // only where it serves every thread. Returns those modules.
565
+ // a module since. Returns the modules served in this executable's own
566
+ // Workers, each thread's among them (dso@0, stack@0).
519
567
  #admit({ requirements, threaded }) {
520
568
  checkHostAbi(requirements, this.hostAbi);
521
569
  const local = [...requirements.keys()].filter(requirement => this.processModules.has(requirement));
522
- const unthreaded = local.filter(requirement => !this.processModules.get(requirement).threads);
523
- if (threaded && unthreaded.length) throw new TypeError(`a program using threads@0 cannot use ${unthreaded.join(", ")}`);
524
570
  if (threaded && !this.threadHost) throw new Error("threads@0 is unavailable");
525
571
  return local;
526
572
  }
527
573
 
528
- #start(process, { module, memoryRequirements, processInterface, threaded, largeStack }, local) {
529
- const memory = createProcessMemory(memoryRequirements);
530
- process.memory = memory;
531
- const gate = new WebAssembly.Instance(this.gateModule, {
532
- process: { memory },
533
- kernel: { memory: this.kernelMemory },
534
- });
535
- Object.assign(process, { gate, module, processInterface, threaded, largeStack, local });
574
+ #start(process, compiled, local) {
575
+ const { memoryRequirements, processInterface, threaded, largeStack } = compiled;
576
+ Object.assign(process, { memoryRequirements, memoryBytes: Number(memoryRequirements.initial) * 65536,
577
+ maximumBytes: Number(memoryRequirements.maximum) * 65536, compiled, processInterface, threaded, largeStack, local });
536
578
  if (threaded) {
537
579
  const tid = this.kernel.dolly_threads_attach(process.pid);
538
580
  if (tid <= 0) throw new Error(`kernel could not reserve main thread: ${tid}`);
@@ -551,10 +593,12 @@ export class DollyProcessSupervisor {
551
593
  const execution = { thread, message };
552
594
  process.execution = execution;
553
595
  let compiled, local;
596
+ const unused = () => compiled && this.#release(compiled);
554
597
  try {
555
598
  compiled = await this.#compiledImage(process.pid);
556
599
  if (process.execution === execution) local = this.#admit(compiled);
557
600
  } catch (error) {
601
+ unused();
558
602
  if (process.execution !== execution) return;
559
603
  process.execution = null;
560
604
  const line = encoder.encode(`dolly: process ${process.pid} cannot run its new program: ` +
@@ -564,10 +608,11 @@ export class DollyProcessSupervisor {
564
608
  this.#signal(thread, message.sequence, -BigInt(DOLLY_ERRNO.ENOEXEC));
565
609
  return;
566
610
  }
567
- if (process.execution !== execution) return;
611
+ if (process.execution !== execution) return unused();
568
612
  process.execution = null;
569
613
  const signalAtStart = this.kernel.dolly_process_exec_commit(process.pid);
570
614
  if (signalAtStart < 0) {
615
+ unused();
571
616
  this.#fail(process, new Error(`kernel rejected process ${process.pid} exec: ${signalAtStart}`));
572
617
  return;
573
618
  }
@@ -577,39 +622,60 @@ export class DollyProcessSupervisor {
577
622
  const delay = this.#reclamationDeadline(process) - performance.now();
578
623
  if (process.interruptTimer !== null) clearTimeout(process.interruptTimer);
579
624
  process.interruptTimer = null;
580
- this.#disposeWorker(process);
625
+ this.#disposeWorker(process, compiled);
581
626
  Object.assign(process, { started: false, threaded: false, tid: 0, threads: new Map(), signalAtStart });
582
627
  // Its descriptors with CLOEXEC are closed: a reader of their pipes goes on now.
583
628
  this.#deliverRaised();
584
629
  if (this.kernel.dolly_process_take_wakeup()) this.serviceDeferred();
585
630
  const start = () => {
586
- if (!this.#canLaunch(process)) return;
587
- try { this.#start(process, compiled, local); } catch (error) { this.#fail(process, error); }
631
+ try {
632
+ if (this.#canLaunch(process)) this.#start(process, compiled, local);
633
+ } catch (error) { this.#fail(process, error); } finally { this.#release(compiled); }
588
634
  };
589
635
  if (delay > 0) setTimeout(start, delay);
590
636
  else start();
591
637
  }
592
638
 
593
- #launchWorker(process, thread, argument = undefined) {
594
- const { pid, module, memory, processInterface, threaded, local } = process;
595
- const control = new SharedArrayBuffer(16);
639
+ // port: for a thread, the channel the spawning Worker sends the memory on.
640
+ #launchWorker(process, thread, argument = undefined, port = undefined) {
641
+ const { pid, compiled: { module }, memoryRequirements, processInterface, threaded, local } = process;
596
642
  const name = `dolly-process-${pid}-thread-${thread.tid}`;
597
643
  const worker = threaded ? this.threadHost.create(pid, this.workerUrl, name)
598
644
  : new Worker(this.workerUrl, { type: "module", name });
599
645
  if (!worker) throw new Error("thread Worker capacity exhausted");
646
+ // Words: the call sequence, the answered sequence, the result's two
647
+ // halves, the copy word (src/process-worker.mjs copyRequest) and a
648
+ // thread's start word (#disposeThread).
649
+ const control = new SharedArrayBuffer(32);
650
+ thread.mailbox = Number(this.kernel.malloc(BigInt(packetLimit)));
651
+ if (!thread.mailbox) { worker.terminate(); throw new Error("kernel memory for a process mailbox"); }
652
+ if (this.kernel.dolly_process_worker_mailbox(pid, thread.tid, BigInt(thread.mailbox)) !== 0) {
653
+ worker.terminate();
654
+ this.kernel.free(BigInt(thread.mailbox));
655
+ thread.mailbox = 0;
656
+ throw new Error(`kernel does not know thread ${thread.tid} of process ${pid}`);
657
+ }
600
658
  thread.worker = worker;
601
659
  thread.control = new Int32Array(control);
602
660
  thread.messageHandler = event => this.#message(process, event.data, thread);
603
- thread.errorHandler = event => this.#fail(process, new Error(event.message || `${name} failed`));
661
+ // Cancelled, or the browser reports the Worker's uncaught error again in
662
+ // this Worker, whose end it would be (src/browser.mjs).
663
+ thread.errorHandler = event => { event.preventDefault(); this.#fail(process, new Error(event.message || `${name} failed`)); };
604
664
  thread.messageErrorHandler = () => this.#fail(process, new Error(`${name} message could not be decoded`));
605
665
  worker.addEventListener("message", thread.messageHandler);
606
666
  worker.addEventListener("error", thread.errorHandler, { once: true });
607
667
  worker.addEventListener("messageerror", thread.messageErrorHandler, { once: true });
608
668
  // local: the modules this executable records that are served in its own
609
669
  // Worker (host/dso), each as the bundle it imports and its configuration.
610
- worker.postMessage({ type: "configure", pid, tid: thread.tid, argument, threaded, module, memory, control,
670
+ // A held module comes from its Worker, on a channel of this Worker's own.
671
+ const channel = module instanceof Worker ? new MessageChannel() : null;
672
+ channel && module.postMessage({ port: channel.port1 }, [channel.port1]);
673
+ worker.postMessage({ type: "configure", pid, tid: thread.tid, argument, threaded, control,
674
+ module: channel ? channel.port2 : module,
675
+ memory: memoryRequirements, port, mailbox: thread.mailbox, kernelMemory: this.kernelMemory, gateModule: this.gateModule,
611
676
  promisingEntry: this.promisingEntry && process.largeStack, stackDepths: this.stackDepths,
612
- clockOrigin: performance.timeOrigin, processInterface, local: local.map(requirement => this.processModules.get(requirement)) });
677
+ clockOrigin: performance.timeOrigin, processInterface, local: local.map(requirement => this.processModules.get(requirement)) },
678
+ [port, channel?.port2].filter(Boolean));
613
679
  }
614
680
 
615
681
  #canLaunch(process) {
@@ -653,8 +719,11 @@ export class DollyProcessSupervisor {
653
719
  this.deferred.delete(thread);
654
720
  }
655
721
 
722
+ // The thread's Worker has copied the request into its mailbox (thread.mailbox)
723
+ // and reads the response from it once answered; what this reads of the
724
+ // mailbox, it reads before it answers.
656
725
  #syscall(process, message, retry = false, thread = process) {
657
- const refusedSince = this.deferred.get(thread)?.refusedSince;
726
+ const pending = this.deferred.get(thread);
658
727
  this.#clearDeferred(thread);
659
728
  const values = [
660
729
  message.sequence,
@@ -663,36 +732,40 @@ export class DollyProcessSupervisor {
663
732
  message.requestSize,
664
733
  message.responseAddress,
665
734
  message.responseCapacity,
735
+ message.memoryBytes,
666
736
  ];
737
+ const spawnsThread = process.threaded && message.operation === DOLLY_THREAD_SPAWN;
667
738
  if (message.pid !== process.pid || message.tid !== thread.tid ||
668
739
  values.some((value) => !Number.isSafeInteger(value) || value < 0) ||
669
740
  message.requestSize > packetLimit || message.responseCapacity > packetLimit ||
741
+ spawnsThread !== (message.port instanceof MessagePort) ||
670
742
  Atomics.load(thread.control, 0) !== message.sequence ||
671
743
  Atomics.load(thread.control, 1) === message.sequence) {
672
744
  this.#fail(process, new Error(`process ${process.pid} sent an invalid syscall`));
673
745
  return;
674
746
  }
747
+ // The size the budget counts: a threaded process keeps the one it reported
748
+ // before its memory took its maximum (src/process-worker.mjs).
749
+ if (!(process.threaded && message.memoryBytes >= process.maximumBytes)) process.memoryBytes = message.memoryBytes;
675
750
  let result;
676
751
  let signalDelivery;
677
752
  let threadArgument;
753
+ let portTaken = false;
678
754
  try {
679
- process.gate.exports.request(
680
- BigInt(message.requestAddress),
681
- BigInt(this.mailboxAddress),
682
- BigInt(message.requestSize),
683
- );
755
+ // A deferred call is retried against the request still in the mailbox:
756
+ // a handler that defers has written nothing (src/process-kernel.h).
757
+ const mailbox = () => new DataView(this.kernelMemory.buffer, thread.mailbox, 8);
684
758
  if (message.operation === DOLLY_THREAD_SPAWN && message.requestSize === 8)
685
- threadArgument = new DataView(this.kernelMemory.buffer, this.mailboxAddress, 8).getBigUint64(0, true);
759
+ threadArgument = mailbox().getBigUint64(0, true);
686
760
  const args = [process.pid, message.operation, BigInt(message.requestSize), BigInt(message.responseCapacity)];
687
761
  if (process.threaded) args.splice(1, 0, thread.tid);
688
762
  const waiting = message.operation === DOLLY_PROCESS_SPAWN ? this.#spawnWait(process.pid) : null;
689
- const now = performance.now(), since = waiting === "count" ? refusedSince ?? now : undefined;
763
+ const now = performance.now(), since = waiting === "count" ? pending?.refusedSince ?? now : undefined;
690
764
  if (waiting === "budget" || waiting === "count" && now - since < reservationPatienceMilliseconds) {
691
765
  this.deferred.set(thread, { process, thread, message, timer: null, refusedSince: since });
692
766
  return;
693
767
  }
694
- const exhausted = waiting === "count" ||
695
- process.threaded && message.operation === DOLLY_THREAD_SPAWN && !this.threadHost?.available(process.pid);
768
+ const exhausted = waiting === "count" || spawnsThread && !this.threadHost?.available(process.pid);
696
769
  // Firefox can enter a direct Wasm call twice. Use the generic call path
697
770
  // so a completed syscall is never replayed against its response packet.
698
771
  result = exhausted ? -BigInt(DOLLY_ERRNO.EAGAIN)
@@ -723,20 +796,14 @@ export class DollyProcessSupervisor {
723
796
  if (result > BigInt(message.responseCapacity)) {
724
797
  throw new Error(`kernel overfilled process ${process.pid} response`);
725
798
  }
726
- process.gate.exports.response(
727
- BigInt(this.mailboxAddress),
728
- BigInt(message.responseAddress),
729
- result,
730
- );
731
799
  if (message.operation === DOLLY_THREAD_SPAWN) {
732
800
  if (result !== 8n || threadArgument === undefined) throw new Error("invalid kernel thread response");
733
- const response = new DataView(this.kernelMemory.buffer, this.mailboxAddress, 8);
734
- const tid = response.getUint32(0, true);
735
- if (!tid || tid > 0x7fffffff || response.getUint32(4, true) || process.threads.has(tid))
801
+ const tid = mailbox().getUint32(0, true);
802
+ if (!tid || tid > 0x7fffffff || mailbox().getUint32(4, true) || process.threads.has(tid))
736
803
  throw new Error("invalid kernel thread identity");
737
- const child = { tid, worker: null, control: null };
804
+ const child = { tid, worker: null, control: null, mailbox: 0 };
738
805
  process.threads.set(tid, child);
739
- try { this.#launchWorker(process, child, threadArgument); }
806
+ try { this.#launchWorker(process, child, threadArgument, message.port); portTaken = true; }
740
807
  catch (error) {
741
808
  this.#disposeThread(process, child);
742
809
  this.kernel.dolly_threads_unstarted(process.pid, tid);
@@ -745,33 +812,32 @@ export class DollyProcessSupervisor {
745
812
  }
746
813
  if (message.operation === DOLLY_PROCESS_SPAWN) {
747
814
  if (result !== 8n) throw new Error("invalid kernel spawn response");
748
- const response = new DataView(this.kernelMemory.buffer, this.mailboxAddress, 8);
749
- const pid = response.getUint32(0, true);
750
- if (pid === 0 || pid > 0x7fffffff || response.getUint32(4, true) !== 0) {
815
+ const pid = mailbox().getUint32(0, true);
816
+ if (pid === 0 || pid > 0x7fffffff || mailbox().getUint32(4, true) !== 0) {
751
817
  throw new Error("invalid kernel spawn identity");
752
818
  }
753
819
  this.#registerProcess(pid, process.pid);
754
820
  }
755
821
  if (message.operation === DOLLY_PROCESS_SIGNAL) {
756
822
  if (result !== 8n) throw new Error("invalid kernel signal response");
757
- const response = new DataView(this.kernelMemory.buffer, this.mailboxAddress, 8);
758
- signalDelivery = { pid: response.getUint32(0, true), signal: response.getUint32(4, true) };
823
+ signalDelivery = { pid: mailbox().getUint32(0, true), signal: mailbox().getUint32(4, true) };
759
824
  if (signalDelivery.pid === 0 || signalDelivery.pid > 0x7fffffff) {
760
825
  throw new Error("invalid kernel signal target");
761
826
  }
762
827
  }
828
+ if (message.operation === DOLLY_PROCESS_SIGNAL_ACKNOWLEDGE && result === 4n &&
829
+ mailbox().getInt32(0, true) === 0 && process.interruptTimer !== null) {
830
+ clearTimeout(process.interruptTimer);
831
+ process.interruptTimer = null;
832
+ }
763
833
  }
764
834
  } catch (error) {
835
+ message.port?.close();
765
836
  this.#fail(process, error);
766
837
  return;
767
838
  }
839
+ if (!portTaken) message.port?.close();
768
840
  this.#signal(thread, message.sequence, result);
769
- if (message.operation === DOLLY_PROCESS_SIGNAL_ACKNOWLEDGE && result === 4n &&
770
- new DataView(this.kernelMemory.buffer, this.mailboxAddress, 4).getInt32(0, true) === 0 &&
771
- process.interruptTimer !== null) {
772
- clearTimeout(process.interruptTimer);
773
- process.interruptTimer = null;
774
- }
775
841
  if (signalDelivery?.signal) {
776
842
  this.#deliverSignal(this.processes.get(signalDelivery.pid), signalDelivery.signal);
777
843
  }
@@ -826,7 +892,7 @@ export class DollyProcessSupervisor {
826
892
 
827
893
  #reclamationDeadline(process) {
828
894
  return Math.max(process.reclamationDeadline,
829
- process.interactive && (process.memory?.buffer.byteLength ?? 0) >= largeInteractiveProcessBytes
895
+ process.interactive && process.memoryBytes >= largeInteractiveProcessBytes
830
896
  ? performance.now() + workerReclamationMilliseconds : 0);
831
897
  }
832
898
 
@@ -949,10 +1015,27 @@ export class DollyProcessSupervisor {
949
1015
  // for two seconds. Answered with the deferred result, which no program
950
1016
  // is handed, a waiting Worker leaves Wasm and closes itself.
951
1017
  if (thread.control) this.#signal(thread, Atomics.load(thread.control, 0), deferredResult);
1018
+ // Chrome keeps what was posted to a Worker terminated before it runs, the
1019
+ // memory a thread's Worker is sent among it, until this Worker collects
1020
+ // garbage. One that has not started (control word 5 still 0) is told so
1021
+ // and ends itself (src/process-worker.mjs processMemory).
1022
+ const unstarted = thread !== process && Atomics.compareExchange(thread.control, 5, 0, 2) === 0;
1023
+ if (unstarted) Atomics.notify(thread.control, 5);
952
1024
  // threads@0 disabled since has already terminated its Workers.
953
- if (process.threaded && this.threadHost) this.threadHost.release(thread.worker);
1025
+ if (process.threaded && this.threadHost) this.threadHost.release(thread.worker, !unstarted);
954
1026
  else thread.worker.terminate();
955
1027
  }
1028
+ // The Worker copies a request into its mailbox under control word 4
1029
+ // (src/process-worker.mjs copyRequest); sealed, it copies no more, and the
1030
+ // mailbox goes back to the kernel. Termination has no completion event: a
1031
+ // Worker ended inside a copy finishes it first.
1032
+ if (thread.mailbox) {
1033
+ this.kernel.dolly_process_worker_mailbox(process.pid, thread.tid, 0n);
1034
+ while (Atomics.compareExchange(thread.control, 4, 0, 2) === 1 &&
1035
+ Atomics.wait(thread.control, 4, 1, mailboxSealMilliseconds) !== "timed-out");
1036
+ if (Atomics.load(thread.control, 4) === 2) this.kernel.free(BigInt(thread.mailbox));
1037
+ }
1038
+ thread.mailbox = 0;
956
1039
  thread.worker = null;
957
1040
  thread.control = null;
958
1041
  thread.messageHandler = thread.errorHandler = thread.messageErrorHandler = null;
@@ -967,10 +1050,13 @@ export class DollyProcessSupervisor {
967
1050
  this.#disposeWorker(process);
968
1051
  }
969
1052
 
970
- #disposeWorker(process) {
1053
+ // next: the module an exec starts next, which stays even if it is this one.
1054
+ #disposeWorker(process, next = null) {
971
1055
  for (const thread of [...process.threads.values()]) this.#disposeThread(process, thread);
972
1056
  this.#disposeThread(process, process);
973
- process.gate = process.memory = process.module = process.processInterface = null;
1057
+ const { compiled } = process;
1058
+ process.compiled = process.processInterface = null;
1059
+ if (compiled && compiled !== next) this.#release(compiled);
974
1060
  }
975
1061
 
976
1062
  serviceDeferred() {
@@ -993,6 +1079,7 @@ export class DollyProcessSupervisor {
993
1079
  }
994
1080
  this.processes.clear();
995
1081
  this.deferred.clear();
1082
+ for (const compiled of this.compiledModules.values()) if (compiled.module instanceof Worker) compiled.module.terminate();
996
1083
  this.compiledModules.clear();
997
1084
  this.compiledModuleBytes = 0;
998
1085
  this.compiledVersions.clear();
@@ -1,17 +1,16 @@
1
1
  // The runtime Worker boot() starts: it restores an image of the image directory
2
2
  // from its packs into a new kernel and runs its ENTRY (worker-image.mjs).
3
- import { DOLLY_IMAGES } from "./images.mjs";
4
- import { loadPackagedSnapshotMetadata, streamPackagedSystemSnapshot } from "./packaged-snapshot.mjs";
3
+ import { imageDefinition } from "./images.mjs";
4
+ import { loadPackagedSnapshotMetadata, streamPackagedSystemSnapshot, verifyVisibleRecipes } from "./packaged-snapshot.mjs";
5
5
  import { siteReference, hex } from "./static-asset.mjs";
6
6
  import { checkedMemoryRange, runImage } from "./worker-image.mjs";
7
7
 
8
- await runImage(async ({ image, mode, files: given = {} }, host) => {
9
- const definition = DOLLY_IMAGES.find(candidate => candidate.image === image);
10
- if (!definition || mode !== "snapshot") throw new Error("invalid Dolly image selection");
11
- const metadata = await loadPackagedSnapshotMetadata(image);
12
- const declared = metadata.hostRequirements ?? definition.hostRequirements ?? [];
13
- host.require(declared);
14
- host.admit(declared);
8
+ await runImage(async ({ image, files: given = {} }, host) => {
9
+ const definition = imageDefinition(image);
10
+ if (!definition) throw new Error("invalid Dolly image selection");
11
+ const [metadata] = await Promise.all([loadPackagedSnapshotMetadata(image), verifyVisibleRecipes(image)]);
12
+ host.require(definition.hostRequirements);
13
+ host.admit(definition.hostRequirements);
15
14
  return {
16
15
  name: image,
17
16
  prepare({ files }) {
@@ -31,7 +30,7 @@ await runImage(async ({ image, mode, files: given = {} }, host) => {
31
30
  if (kernel.dolly_bootstrap_snapshot_begin(BigInt(metadata.byteLength)) !== 0)
32
31
  throw new Error("Dolly snapshot stream initialization failed");
33
32
  let loaded = 0, reported = 0;
34
- await streamPackagedSystemSnapshot(image, metadata, bytes => {
33
+ await streamPackagedSystemSnapshot(metadata, bytes => {
35
34
  for (let offset = 0; offset < bytes.length; offset += capacity) {
36
35
  const chunk = bytes.subarray(offset, offset + capacity);
37
36
  staging().set(chunk);