@volter/browser-wali 0.1.5 → 0.1.7

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/runtime.js CHANGED
@@ -1,24 +1,31 @@
1
+ import { createWaliSignalState, defaultTerminates, WaliSignals, WaliSignalHost } from "./signals.js";
2
+ import { createConfinedWaliSpawnImport } from "./spawn-policy.js";
3
+ import { normalizeProcessFilesystemPolicy, intersectProcessFilesystemPolicies } from "@volter/browser-runtime/browser-process-filesystem-policy.js";
1
4
  import { assertWaliModule, compileWaliModule, inspectWaliModule } from "./inspect.js";
5
+ import { WALI_ENOSYS, waliUnimplementedTraps } from "./capabilities.js";
6
+ import { WaliDescriptionRegistry, WaliProcessDescriptorTable } from "./descriptors.js";
2
7
  import { createStdioAliasRegistry, WaliFileDescriptorTable } from "./filesystem.js";
3
8
  import { createWaliAllocatorControl, createWaliSharedMmapRegistry, WaliMemoryManager } from "./memory.js";
4
9
  import { WaliPthreadProcessExit, WaliSystemHost } from "./system.js";
5
10
  import { WaliEventHost } from "./events.js";
6
- import { createImportedMemory } from "./module-layout.js";
11
+ import { createImportedMemory, findImportedMemory } from "./module-layout.js";
7
12
  import { WaliThreadPool } from "./threads.js";
8
13
  import { WaliLocalSocketHost } from "./local-sockets.js";
9
14
  import { WaliNetworkCoordinator, WaliNetworkHost } from "./network.js";
10
15
  import { BufferedWaliStdin, SharedWaliStdinReader } from "./stdin.js";
11
- import { WaliFilesystemCoordinator, WaliSharedFileDescriptorHost } from "./filesystem-remote.js";
16
+ import { WaliFilesystemClient, WaliFilesystemCoordinator, WaliSharedFileDescriptorHost } from "./filesystem-remote.js";
12
17
  import { createWaliSharedPipeState, WaliSharedPipeHost } from "./shared-pipes.js";
18
+ import { createWaliCodecImports, WaliCodecClient, WaliCodecCoordinator } from "./codec-remote.js";
19
+ import { createSelectImport, SELECT_SCRATCH_BYTES } from "./select.js";
13
20
  import { applyProcessFilesystemPatch, BROWSER_PROCESS_RESPONSE_BYTES, closeSharedPipe, decodeProcessSnapshot, retainSharedPipe, } from "./process-bridge.js";
14
21
  import { BrowserFileLockClient } from "@volter/browser-runtime/browser-file-locks.js";
15
22
  import { BrowserFileCoherenceClient } from "@volter/browser-runtime/browser-file-coherence.js";
16
23
  import { WaliCoherentFileLockClient } from "./coherent-file-locks.js";
24
+ import { monotonicMilliseconds } from "./monotonic-clock.js";
17
25
  const EBADF = 9n;
18
26
  const EINVAL = 22n;
19
27
  const encoder = new TextEncoder();
20
28
  const decoder = new TextDecoder();
21
- const WNOHANG = 1;
22
29
  export class UnsupportedWaliImportError extends Error {
23
30
  imports;
24
31
  constructor(imports) {
@@ -27,6 +34,45 @@ export class UnsupportedWaliImportError extends Error {
27
34
  this.name = "UnsupportedWaliImportError";
28
35
  }
29
36
  }
37
+ /**
38
+ * The handler for an import this runtime does not implement.
39
+ *
40
+ * `capabilities.ts` holds the rule and the evidence: a `SYS_*` name answers
41
+ * `-ENOSYS` the way a kernel does, and the four names in
42
+ * `WALI_TRAPPING_IMPORTS` still take the process down because an errno there
43
+ * cannot reach a caller that can act on it. `thread-worker.ts` does the
44
+ * identical thing for every pthread.
45
+ *
46
+ * WHERE THE ONE-SHOT NOTICE LANDS. This runtime runs inside
47
+ * `worker-program-worker.ts`, a nested worker, so `console.error` alone reaches
48
+ * that worker's devtools console and NOTHING ELSE -- measured 2026-09-19
49
+ * through the vgai editor, where an `os.fork()` answered -ENOSYS left
50
+ * `vgai console`, the product's own unresolved-console door, silent, because
51
+ * the editor's collector reports only what THE PAGE sees. A trap reached that
52
+ * door through the worker's `{type:"result", error}` message; an answered
53
+ * syscall had nothing to travel on. `onDiagnostic` is that something: the
54
+ * runtime's own word to its host, carried by `{type:"diagnostic"}` on
55
+ * `WaliWorkerResponse` and by the pthread output ports, landing on
56
+ * `BrowserProcessRunOptions.onDiagnostic`. It is deliberately NOT stdout: a
57
+ * kernel does not write to your streams, and a host that parses the program's
58
+ * output would have to un-parse this line again.
59
+ */
60
+ function unimplementedImport(name, report) {
61
+ if (waliUnimplementedTraps(name))
62
+ return () => { throw new UnsupportedWaliImportError([name]); };
63
+ let reported = false;
64
+ return () => {
65
+ if (!reported) {
66
+ reported = true;
67
+ const notice = `[WALI] ${name} is not implemented; answering -ENOSYS (38) as a kernel would.`;
68
+ console.error(notice);
69
+ report?.(notice);
70
+ }
71
+ return WALI_ENOSYS;
72
+ };
73
+ }
74
+ class WaliProcessReplaced extends Error {
75
+ }
30
76
  class WaliProcessExit extends Error {
31
77
  code;
32
78
  constructor(code) {
@@ -36,6 +82,9 @@ class WaliProcessExit extends Error {
36
82
  }
37
83
  export class BrowserWaliRuntime {
38
84
  async run(source, options = {}) {
85
+ const inheritedFilesystemPolicy = options.filesystemPolicy === undefined ? undefined
86
+ : normalizeProcessFilesystemPolicy(options.filesystemPolicy);
87
+ const filesystemPolicy = inheritedFilesystemPolicy;
39
88
  const module = await compileWaliModule(source);
40
89
  const descriptors = inspectWaliModule(module);
41
90
  assertWaliModule(descriptors);
@@ -48,10 +97,17 @@ export class BrowserWaliRuntime {
48
97
  const argv = options.argv?.length ? [...options.argv] : ["wali-program"];
49
98
  const argvBytes = argv.map((argument) => encoder.encode(`${argument}\0`));
50
99
  const pid = normalizePid(options.pid);
51
- const signalActions = new Map();
100
+ // Where a run's time goes: setup, then the guest, and within it each
101
+ // syscall by name. A run longer than PROFILE_AFTER_MS prints it once.
102
+ const runStartedAt = performance.now();
103
+ let startEnteredAt = runStartedAt;
104
+ const syscallTime = new Map();
105
+ const signalState = options.signalState ?? createWaliSignalState();
52
106
  let instance;
53
107
  let threadPool;
54
108
  let networkCoordinator;
109
+ let codecCoordinator;
110
+ let codecClient;
55
111
  let filesystemCoordinator;
56
112
  const importedMemory = createImportedMemory(source, options.memory);
57
113
  const stdioAliasRegistry = createStdioAliasRegistry();
@@ -61,7 +117,8 @@ export class BrowserWaliRuntime {
61
117
  // Word 1: first failing worker slot. Word 2: process exit code.
62
118
  const pthreadFatal = new Int32Array(new SharedArrayBuffer(3 * Int32Array.BYTES_PER_ELEMENT));
63
119
  Atomics.store(pthreadFatal, 1, -1);
64
- const sharedPipeState = createWaliSharedPipeState();
120
+ const localDescriptors = new WaliProcessDescriptorTable(new WaliDescriptionRegistry());
121
+ const sharedPipeState = options.descriptorTable?.sharedPipes ?? createWaliSharedPipeState(localDescriptors);
65
122
  const memory = () => {
66
123
  if (importedMemory)
67
124
  return importedMemory;
@@ -94,18 +151,70 @@ export class BrowserWaliRuntime {
94
151
  }
95
152
  return BigInt(value.byteLength);
96
153
  };
97
- const exit = (status) => { throw new WaliProcessExit(status & 0xff); };
154
+ /**
155
+ * THE MAIN THREAD ENDING THE PROCESS SAYS SO, the same way a pthread does.
156
+ *
157
+ * `thread-worker.ts`'s `SYS_exit_group` already names the WORKER that ends
158
+ * a process, because a bare "exited 0" reads as an orderly shutdown and is
159
+ * the most misleading answer this runtime can give. This is the other half:
160
+ * without it, the ABSENCE of the worker's notice is the only evidence that
161
+ * the main thread was the one that exited, which is reasoning from silence.
162
+ *
163
+ * MEASURED 2026-09-19 (WS-W): headless Blender ended mid-call on the WALI
164
+ * skew and the worker notice did NOT fire, so the exit came through here --
165
+ * a fact that took a second run to establish and that this line states
166
+ * directly. Which syscall and which status are both part of the answer:
167
+ * `SYS_exit` from the main thread and `SYS_exit_group` mean different
168
+ * things about who decided.
169
+ */
170
+ const exit = (status, name = "SYS_exit_group") => {
171
+ const notice = `[WALI] ${name}(${status & 0xff}) on the MAIN thread -- the process is ending here,`
172
+ + ` not in a pthread. If the program did not ask to exit, this is the caller to find.`;
173
+ console.error(notice);
174
+ options.onDiagnostic?.(notice);
175
+ throw new WaliProcessExit(status & 0xff);
176
+ };
177
+ const signalSelf = (signal) => {
178
+ if (signal < 0 || signal > 64)
179
+ return -22n;
180
+ if (signal === 0)
181
+ return 0n;
182
+ // blocked, it stays pending until the mask admits it (raise queues only for a handler)
183
+ if (signal !== 9 && (signalHost.mask & (1n << BigInt(signal - 1))) !== 0n) {
184
+ new WaliSignals(signalState).raise(signal);
185
+ return 0n;
186
+ }
187
+ const disposition = signal === 9 || signal === 19 ? "default" : new WaliSignals(signalState).raise(signal);
188
+ if (disposition === "default" && defaultTerminates(signal))
189
+ exit(128 + signal, `signal ${signal}`);
190
+ return 0n;
191
+ };
98
192
  const executablePath = options.executablePath
99
193
  ?? (argv[0].startsWith("/") ? argv[0] : `/usr/bin/${argv[0]}`);
100
194
  const threaded = waliDescriptors.some((descriptor) => descriptor.name === "__wasm_thread_spawn");
101
- const mmapCursor = createWaliAllocatorControl();
195
+ // A module that imports none of the calls that make a child never has
196
+ // one: it needs neither the filesystem worker that shares descriptors
197
+ // with children nor the network worker that carries their pipes, and
198
+ // starting those was most of a small program's setup (ADR-0042).
199
+ const makesChildren = waliDescriptors.some((descriptor) => CHILD_PROCESS_SYSCALLS.has(descriptor.name));
200
+ const childDoor = makesChildren ? options.browserProcess : undefined;
201
+ const mmapCursor = options.forkState?.allocator ?? createWaliAllocatorControl();
102
202
  const sharedMmapRegistry = createWaliSharedMmapRegistry();
103
- if (threaded && options.filesystem?.toSnapshot && options.filesystem.replaceSnapshot) {
203
+ const inheritedDescriptors = options.descriptorTable ? new WaliFilesystemClient(options.descriptorTable) : undefined;
204
+ if (inheritedDescriptors && importedMemory)
205
+ inheritedDescriptors.attachMemory(importedMemory);
206
+ if (!inheritedDescriptors && (threaded || childDoor) && options.filesystem?.toSnapshot && options.filesystem.replaceSnapshot) {
104
207
  if (!importedMemory) {
105
208
  throw new Error("A threaded WALI module must import its shared process memory.");
106
209
  }
107
- filesystemCoordinator = await WaliFilesystemCoordinator.create(options.filesystem.toSnapshot(), {
210
+ // A filesystem worker over the same store is handed only what differs
211
+ // from it, not the store's whole index again (ADR-0042).
212
+ const overlay = options.store ? options.filesystem.toStoreOverlaySnapshot?.() : undefined;
213
+ filesystemCoordinator = await WaliFilesystemCoordinator.create(overlay ?? options.filesystem.toSnapshot(), {
214
+ ...(options.store ? { store: options.store } : {}),
108
215
  memory: importedMemory,
216
+ sharedPipes: sharedPipeState,
217
+ ...(filesystemPolicy ? { filesystemPolicy } : {}),
109
218
  cwd: options.cwd ?? "/",
110
219
  executablePath,
111
220
  pid,
@@ -114,13 +223,18 @@ export class BrowserWaliRuntime {
114
223
  ...(options.fileCoherence ? { fileCoherence: options.fileCoherence } : {}),
115
224
  });
116
225
  }
117
- const runtimeFilesystem = filesystemCoordinator?.filesystem() ?? options.filesystem;
226
+ if (filesystemPolicy && threaded && !filesystemCoordinator && !inheritedDescriptors)
227
+ throw new Error("Confined WALI threads require the shared filesystem owner.");
228
+ const runtimeFilesystem = inheritedDescriptors ?? filesystemCoordinator?.filesystem() ?? options.filesystem;
118
229
  const syncFilesystem = () => {
119
230
  if (filesystemCoordinator && options.filesystem?.replaceSnapshot) {
120
231
  options.filesystem.replaceSnapshot(filesystemCoordinator.snapshot());
121
232
  }
122
233
  };
123
234
  const fileDescriptors = new WaliFileDescriptorTable({
235
+ ...((inheritedDescriptors || filesystemCoordinator) ? { descriptorState: sharedPipeState }
236
+ : { descriptors: localDescriptors }),
237
+ ...(filesystemPolicy ? { filesystemPolicy } : {}),
124
238
  ...(runtimeFilesystem ? { filesystem: runtimeFilesystem } : {}),
125
239
  cwd: options.cwd ?? "/",
126
240
  stdin,
@@ -138,17 +252,25 @@ export class BrowserWaliRuntime {
138
252
  ...(filesystemCoordinator ? { specialDescriptorMinimum: 10_000, descriptorMaximum: 19_999 } : {}),
139
253
  });
140
254
  const environmentFile = fileDescriptors.writeEnvironmentFile(options.env ?? {}, pid);
141
- const sharedFileHost = filesystemCoordinator
142
- ? new WaliSharedFileDescriptorHost(filesystemCoordinator.filesystem(), stdioAliasRegistry) : undefined;
255
+ const sharedFileClient = inheritedDescriptors ?? filesystemCoordinator?.filesystem();
256
+ const sharedFileHost = sharedFileClient
257
+ ? new WaliSharedFileDescriptorHost(sharedFileClient, stdioAliasRegistry, sharedPipeState) : undefined;
143
258
  const memoryManager = new WaliMemoryManager(memory, (fd, offset, length) => sharedFileHost?.readForMapping(fd, offset, length)
144
259
  ?? fileDescriptors.readForMapping(fd, offset, length), mmapCursor, (fd) => sharedFileHost?.mappingPath(fd) ?? fileDescriptors.mappingPath(fd), (path, offset, value) => sharedFileHost?.writeForMapping(path, offset, value)
145
- ?? fileDescriptors.writeForMapping(path, offset, value), sharedMmapRegistry);
146
- const systemHost = new WaliSystemHost(memory, pid, stdin, pthreadFatal);
147
- if (options.network) {
148
- networkCoordinator = await WaliNetworkCoordinator.create(options.network, sharedPipeState, stdin instanceof SharedWaliStdinReader ? stdin.buffer : undefined, stdioAliasRegistry.buffer);
260
+ ?? fileDescriptors.writeForMapping(path, offset, value), sharedMmapRegistry, options.onDiagnostic);
261
+ const signalHost = new WaliSignalHost(new WaliSignals(signalState), memory, () => instance?.exports.__indirect_function_table, () => Number(memoryManager.imports().SYS_mmap(0, 4096, 3, 0x22, -1, 0)));
262
+ // The same number the thread pool is built with below, so the CPU count
263
+ // the guest is told about can never exceed the workers that exist.
264
+ const threadPoolSize = options.threadPoolSize ?? 18;
265
+ const systemHost = new WaliSystemHost(memory, pid, stdin, pthreadFatal, options.terminal, threadPoolSize);
266
+ // One shot: a program that exhausts the pool usually keeps asking, and the
267
+ // hundredth copy of the notice buries the first.
268
+ let threadPoolExhaustionReported = false;
269
+ if (options.network || childDoor) {
270
+ networkCoordinator = await WaliNetworkCoordinator.create(options.network, sharedPipeState, stdin instanceof SharedWaliStdinReader ? stdin.buffer : undefined, stdioAliasRegistry.buffer, signalState);
149
271
  }
150
- const networkHost = networkCoordinator ? new WaliNetworkHost(memory, networkCoordinator.createChannel()) : undefined;
151
- const sharedPipeHost = new WaliSharedPipeHost(memory, sharedPipeState);
272
+ const networkHost = networkCoordinator ? new WaliNetworkHost(memory, networkCoordinator.createChannel(), signalHost) : undefined;
273
+ const sharedPipeHost = new WaliSharedPipeHost(memory, sharedPipeState, sharedFileClient ? undefined : localDescriptors, sharedFileClient);
152
274
  const eventHost = new WaliEventHost(memory, fileDescriptors, (fd, events) => sharedPipeHost.pollDescriptor(fd, events) ?? (networkHost?.isNetworkFd(fd)
153
275
  ? networkHost.pollFd(fd, events) : undefined));
154
276
  const localSocketHost = new WaliLocalSocketHost(memory, fileDescriptors);
@@ -157,9 +279,13 @@ export class BrowserWaliRuntime {
157
279
  const pipeImports = sharedPipeHost.imports({ ...localSocketHost.imports(), ...fileImports });
158
280
  const eventImports = eventHost.imports();
159
281
  const systemImports = systemHost.imports();
160
- const childProcesses = new Map();
161
- const nextChildPid = new Int32Array(new SharedArrayBuffer(Int32Array.BYTES_PER_ELEMENT));
162
- Atomics.store(nextChildPid, 0, Math.max(pid + 1, 10_000));
282
+ // A forked runtime learns its parent from the fork state; one the spawn
283
+ // door exec'd into an existing process is told; a first process's parent
284
+ // is init.
285
+ const parentProcessId = options.forkState?.parentPid ?? options.parentPid ?? 1;
286
+ const nextChildPid = new Int32Array(options.childPidAllocator ?? new SharedArrayBuffer(Int32Array.BYTES_PER_ELEMENT));
287
+ if (!options.childPidAllocator)
288
+ Atomics.store(nextChildPid, 0, Math.max(pid + 1, 10_000));
163
289
  const readCString = (pointer) => {
164
290
  if (!Number.isSafeInteger(pointer) || pointer <= 0 || pointer >= bytes().byteLength) {
165
291
  throw new WebAssembly.RuntimeError("WALI process string pointer is out of bounds.");
@@ -188,51 +314,164 @@ export class BrowserWaliRuntime {
188
314
  return;
189
315
  return retainSharedPipe(handle);
190
316
  };
191
- const applyChildSnapshot = (process) => {
192
- if (process.snapshotApplied)
193
- return;
194
- process.snapshotApplied = true;
317
+ const waitForChild = (requestedPid, statusPointer, optionsValue) => {
318
+ if (!networkHost)
319
+ return -10n;
320
+ if (statusPointer !== 0 && (statusPointer < 0 || statusPointer + 4 > bytes().byteLength))
321
+ return -14n;
322
+ const { result, process } = networkHost.waitChild(requestedPid, optionsValue, signalHost.mask);
323
+ if (result <= 0)
324
+ return BigInt(result);
195
325
  const patch = decodeProcessSnapshot(process);
196
326
  if (patch) {
197
327
  if (filesystemCoordinator)
198
- applyProcessFilesystemPatch(filesystemCoordinator.filesystem(), patch);
328
+ filesystemCoordinator.filesystem().importPeerPatch(patch);
199
329
  if (options.filesystem)
200
330
  applyProcessFilesystemPatch(options.filesystem, patch);
201
331
  const snapshot = options.filesystem?.toSnapshot?.();
202
332
  if (snapshot)
203
333
  options.browserProcess?.restore?.(snapshot);
204
334
  }
335
+ if (statusPointer !== 0)
336
+ view().setInt32(statusPointer, (Atomics.load(new Int32Array(process.control), 1) & 0xff) << 8, true);
337
+ return BigInt(result);
205
338
  };
206
- const waitForChild = (requestedPid, statusPointer, optionsValue) => {
207
- let childPid;
208
- let process;
209
- if (requestedPid === -1) {
210
- const entry = childProcesses.entries().next().value;
211
- if (!entry)
212
- return -10n;
213
- [childPid, process] = entry;
339
+ const spawnBrowserProcess = (args, filesystemPolicy) => {
340
+ const [pathname, argvPointer, envPointer, cwdPointer, stdinFd, stdoutFd, stderrFd] = args;
341
+ if (!options.browserProcess)
342
+ return -38;
343
+ if (filesystemPolicy)
344
+ filesystemPolicy = normalizeProcessFilesystemPolicy(filesystemPolicy);
345
+ const inheritedPolicy = inheritedFilesystemPolicy;
346
+ if (inheritedPolicy) {
347
+ if (options.browserProcess.filesystemPolicyVersion !== 1)
348
+ return -95;
349
+ filesystemPolicy = intersectProcessFilesystemPolicies(inheritedPolicy, filesystemPolicy);
214
350
  }
215
- else {
216
- const exact = childProcesses.get(requestedPid);
217
- if (!exact)
218
- return -10n;
219
- childPid = requestedPid;
220
- process = exact;
351
+ const executablePath = readCString(Number(pathname));
352
+ const argvValue = readCStringArray(Number(argvPointer));
353
+ if (argvValue.length === 0)
354
+ argvValue.push(executablePath);
355
+ const envValue = {};
356
+ for (const entry of readCStringArray(Number(envPointer))) {
357
+ const separator = entry.indexOf("=");
358
+ if (separator > 0)
359
+ envValue[entry.slice(0, separator)] = entry.slice(separator + 1);
360
+ }
361
+ const childPid = Atomics.add(nextChildPid, 0, 1);
362
+ const process = {
363
+ control: new SharedArrayBuffer(8 * Int32Array.BYTES_PER_ELEMENT),
364
+ response: new SharedArrayBuffer(BROWSER_PROCESS_RESPONSE_BYTES),
365
+ snapshotApplied: false,
366
+ };
367
+ const stdin = exportPipe(Number(stdinFd), "reader");
368
+ const stdout = exportPipe(Number(stdoutFd), "writer");
369
+ const stderr = exportPipe(Number(stderrFd), "writer");
370
+ let childTable;
371
+ try {
372
+ const registered = networkHost.registerChild(childPid, process);
373
+ if (registered < 0)
374
+ throw new Error(`Child registration failed: ${registered}`);
375
+ sharedFileClient?.prepareSpawn();
376
+ syncFilesystem();
377
+ childTable = sharedFileClient?.copyForExecChannel([Number(stdinFd), Number(stdoutFd), Number(stderrFd)]);
378
+ options.browserProcess.spawn({
379
+ // A NEW process here, not an exec in place: its parent is this one,
380
+ // and it and its own children come from the container's counter.
381
+ pid: childPid,
382
+ parentPid: pid,
383
+ childPidAllocator: nextChildPid.buffer,
384
+ ...(childTable ? { descriptorTable: childTable } : {}),
385
+ ...(filesystemPolicy ? { filesystemPolicy } : {}),
386
+ executablePath,
387
+ argv: argvValue,
388
+ env: envValue,
389
+ ...(Number(envPointer) === 0 ? { inheritEnvironment: true } : {}),
390
+ cwd: Number(cwdPointer) === 0 ? fileDescriptors.currentWorkingDirectory() : readCString(Number(cwdPointer)),
391
+ control: process.control,
392
+ response: process.response,
393
+ ...(stdin ? { stdin } : {}),
394
+ ...(stdout ? { stdout } : {}),
395
+ ...(stderr ? { stderr } : {}),
396
+ });
397
+ return childPid;
398
+ }
399
+ catch {
400
+ childTable?.releasePort?.postMessage(true);
401
+ networkHost.forgetChild(childPid);
402
+ if (stdin)
403
+ closeSharedPipe(stdin);
404
+ if (stdout)
405
+ closeSharedPipe(stdout);
406
+ if (stderr)
407
+ closeSharedPipe(stderr);
408
+ return -5;
409
+ }
410
+ };
411
+ let asyncifyData = options.forkState?.asyncifyData ?? 0;
412
+ let unwinding = false;
413
+ let rewinding = Boolean(options.forkState);
414
+ let forkResult = 0;
415
+ let forkNotice = false;
416
+ const asyncify = (name, ...args) => {
417
+ const fn = instance?.exports[name];
418
+ if (typeof fn !== "function")
419
+ throw new Error(`Missing ${name}; build the module with the Binaryen Asyncify stage.`);
420
+ fn(...args);
421
+ };
422
+ const currentCwd = () => sharedFileClient?.currentWorkingDirectory() ?? fileDescriptors.currentWorkingDirectory();
423
+ const fork = () => {
424
+ if (typeof instance?.exports.asyncify_start_unwind !== "function") {
425
+ if (!forkNotice)
426
+ options.onDiagnostic?.("[WALI] SYS_fork answers -ENOSYS: module lacks the Binaryen Asyncify build stage (--asyncify --pass-arg=asyncify-imports@wali.SYS_fork).");
427
+ forkNotice = true;
428
+ return -38n;
221
429
  }
222
- const control = new Int32Array(process.control);
223
- if (Atomics.load(control, 0) === 0) {
224
- if ((optionsValue & WNOHANG) !== 0)
225
- return 0n;
226
- while (Atomics.load(control, 0) === 0)
227
- Atomics.wait(control, 0, 0, 1000);
430
+ if (rewinding) {
431
+ asyncify("asyncify_stop_rewind");
432
+ rewinding = false;
433
+ return BigInt(forkResult);
228
434
  }
229
- applyChildSnapshot(process);
230
- if (statusPointer !== 0) {
231
- assertMemoryRange(memory(), statusPointer, 4);
232
- view().setInt32(statusPointer, (Atomics.load(control, 1) & 0xff) << 8, true);
435
+ if (!options.browserProcess?.fork || !sharedFileClient || !networkHost)
436
+ return -38n;
437
+ unwinding = true;
438
+ view().setUint32(asyncifyData, asyncifyData + 8, true);
439
+ asyncify("asyncify_start_unwind", asyncifyData);
440
+ return 0n;
441
+ };
442
+ const execve = (pathname, argvPointer, envPointer) => {
443
+ if (!options.forkState || !options.browserProcess || !sharedFileClient)
444
+ return -38n;
445
+ const path = readCString(pathname);
446
+ const executable = path.startsWith("/") ? path : `${currentCwd()}/${path}`;
447
+ if (!runtimeFilesystem?.existsSync(executable))
448
+ return -2n;
449
+ if (!runtimeFilesystem.statSync(executable).isFile())
450
+ return -13n;
451
+ // A NULL envp is the process's own environment (see inheritEnvironment).
452
+ const env = envPointer === 0 ? { ...options.env } : {};
453
+ for (const entry of readCStringArray(envPointer)) {
454
+ const separator = entry.indexOf("=");
455
+ if (separator > 0)
456
+ env[entry.slice(0, separator)] = entry.slice(separator + 1);
233
457
  }
234
- childProcesses.delete(childPid);
235
- return BigInt(childPid);
458
+ sharedFileClient.prepareSpawn();
459
+ const table = sharedFileClient.copyForExecChannel([0, 1, 2]);
460
+ const stdin = exportPipe(0, "reader");
461
+ const stdout = exportPipe(1, "writer");
462
+ const stderr = exportPipe(2, "writer");
463
+ options.browserProcess.spawn({
464
+ // The kernel's exec: the image changes, the process does not. The pid
465
+ // and the parent go across, and the child numbers ITS children from
466
+ // the container's one allocator -- two runtimes each starting their
467
+ // own at 10000 hand out the same pid twice.
468
+ pid, parentPid: parentProcessId, childPidAllocator: nextChildPid.buffer,
469
+ executablePath: executable, argv: readCStringArray(argvPointer), env, cwd: currentCwd(),
470
+ descriptorTable: table, control: options.forkState.control, response: options.forkState.response,
471
+ ...(filesystemPolicy ? { filesystemPolicy } : {}),
472
+ ...(stdin ? { stdin } : {}), ...(stdout ? { stdout } : {}), ...(stderr ? { stderr } : {}),
473
+ });
474
+ throw new WaliProcessReplaced();
236
475
  };
237
476
  const builtins = {
238
477
  __init: () => 0,
@@ -264,10 +503,10 @@ export class BrowserWaliRuntime {
264
503
  }
265
504
  return written;
266
505
  },
267
- SYS_exit: (status) => exit(Number(status)),
268
- SYS_exit_group: (status) => exit(Number(status)),
506
+ SYS_exit: (status) => exit(Number(status), "SYS_exit"),
507
+ SYS_exit_group: (status) => exit(Number(status), "SYS_exit_group"),
269
508
  SYS_getpid: () => BigInt(pid),
270
- SYS_getppid: () => 1n,
509
+ SYS_getppid: () => BigInt(parentProcessId),
271
510
  SYS_gettid: () => BigInt(pid),
272
511
  SYS_getuid: () => 1000n,
273
512
  SYS_geteuid: () => 1000n,
@@ -280,83 +519,71 @@ export class BrowserWaliRuntime {
280
519
  // advertises it; a program calling it on its main thread was the one
281
520
  // path that still failed closed.
282
521
  SYS_prctl: () => 0n,
283
- SYS_tkill: () => 0n,
284
- SYS_browser_spawn: (pathname, argvPointer, envPointer, cwdPointer, stdinFd, stdoutFd, stderrFd) => {
285
- if (!options.browserProcess)
286
- return -38;
287
- const executablePath = readCString(Number(pathname));
288
- const argvValue = readCStringArray(Number(argvPointer));
289
- if (argvValue.length === 0)
290
- argvValue.push(executablePath);
291
- const envValue = {};
292
- for (const entry of readCStringArray(Number(envPointer))) {
293
- const separator = entry.indexOf("=");
294
- if (separator > 0)
295
- envValue[entry.slice(0, separator)] = entry.slice(separator + 1);
296
- }
297
- const childPid = Atomics.add(nextChildPid, 0, 1);
298
- const process = {
299
- control: new SharedArrayBuffer(8 * Int32Array.BYTES_PER_ELEMENT),
300
- response: new SharedArrayBuffer(BROWSER_PROCESS_RESPONSE_BYTES),
301
- snapshotApplied: false,
302
- };
303
- const stdin = exportPipe(Number(stdinFd), "reader");
304
- const stdout = exportPipe(Number(stdoutFd), "writer");
305
- const stderr = exportPipe(Number(stderrFd), "writer");
306
- childProcesses.set(childPid, process);
307
- try {
308
- syncFilesystem();
309
- options.browserProcess.spawn({
310
- pid: childPid,
311
- executablePath,
312
- argv: argvValue,
313
- env: envValue,
314
- cwd: Number(cwdPointer) === 0 ? fileDescriptors.currentWorkingDirectory() : readCString(Number(cwdPointer)),
315
- control: process.control,
316
- response: process.response,
317
- ...(stdin ? { stdin } : {}),
318
- ...(stdout ? { stdout } : {}),
319
- ...(stderr ? { stderr } : {}),
320
- });
321
- return childPid;
322
- }
323
- catch {
324
- childProcesses.delete(childPid);
325
- if (stdin)
326
- closeSharedPipe(stdin);
327
- if (stdout)
328
- closeSharedPipe(stdout);
329
- if (stderr)
330
- closeSharedPipe(stderr);
331
- return -5;
522
+ // A signal the process sends itself (musl's raise is tkill on its own
523
+ // tid): a handler runs when the call returns, SIG_DFL ends the process
524
+ // for the signals whose default is to end it, as with 128 + the signal.
525
+ SYS_tkill: (_tid, signal) => signalSelf(Number(signal)),
526
+ // The codec bridge is negotiated by the manifest's `media.webcodecs`;
527
+ // without it every one of the six answers -95 before an argument is read.
528
+ ...createWaliCodecImports(memory, () => options.codec?.version === 1, () => codecClient),
529
+ SYS_browser_spawn: (...args) => spawnBrowserProcess(args),
530
+ SYS_browser_spawn_confined_v1: createConfinedWaliSpawnImport(memory, () => options.browserProcess?.filesystemPolicyVersion === 1, spawnBrowserProcess),
531
+ SYS_fork: fork,
532
+ SYS_execve: (path, argv, env) => execve(Number(path), Number(argv), Number(env)),
533
+ // interrupted by a signal: its handler runs here, and a handler that
534
+ // asked for SA_RESTART (musl's signal() does) has the wait made again
535
+ SYS_wait4: (childPid, status, optionsValue) => {
536
+ for (;;) {
537
+ const result = waitForChild(Number(childPid), Number(status), Number(optionsValue));
538
+ if (result !== -4n || !signalHost.deliver())
539
+ return result;
332
540
  }
333
541
  },
334
- SYS_wait4: (childPid, status, optionsValue) => waitForChild(Number(childPid), Number(status), Number(optionsValue)),
542
+ SYS_pidfd_open: () => -38n,
335
543
  SYS_kill: (childPid, signal) => {
544
+ if (Number(childPid) === pid || Number(childPid) === 0)
545
+ return signalSelf(Number(signal));
336
546
  const normalized = Math.abs(Number(childPid));
337
- const process = childProcesses.get(normalized);
338
- if (!process)
547
+ if (!networkHost?.hasChild(normalized))
339
548
  return -3n;
340
- Atomics.store(new Int32Array(process.control), 3, Number(signal));
341
549
  options.browserProcess?.kill?.(normalized, Number(signal));
342
550
  return 0n;
343
551
  },
344
552
  SYS_setpgid: () => 0n,
345
553
  SYS_getpgid: (childPid) => BigInt(Number(childPid) === 0 ? pid : Math.abs(Number(childPid))),
346
- __wasm_thread_spawn: (functionPointer, argumentPointer) => threadPool?.spawn(Number(functionPointer), Number(argumentPointer)) ?? -11,
347
- SYS_rt_sigaction: (signal, action, previous, setSize) => {
348
- const signalNumber = Number(signal);
349
- const actionPointer = Number(action);
350
- const previousPointer = Number(previous);
351
- if (signalNumber < 1 || signalNumber > 64 || Number(setSize) !== 8)
352
- return -EINVAL;
353
- if (actionPointer !== 0 && (signalNumber === 9 || signalNumber === 19))
354
- return -EINVAL;
355
- if (previousPointer !== 0)
356
- copyIn(previousPointer, signalActions.get(signalNumber) ?? new Uint8Array(20));
357
- if (actionPointer !== 0)
358
- signalActions.set(signalNumber, copyOut(actionPointer, 20));
359
- return 0n;
554
+ /**
555
+ * POOL EXHAUSTION SAYS SO, ONCE, because -EAGAIN is invisible from here up.
556
+ *
557
+ * The pool is instantiated before `_start` and never grows, so it is the
558
+ * process's whole THREAD budget; `WaliThreadPool.spawn` answers -EAGAIN
559
+ * (-11) when no slot is free, exactly as a kernel out of task structs
560
+ * would. What the guest does with that is the problem: libc++ turns it
561
+ * into `std::system_error: thread constructor failed: Resource
562
+ * temporarily unavailable`, and an uncaught one leaves the module as the
563
+ * host string `[object WebAssembly.Exception]` with nothing naming a
564
+ * thread, a pool or a number.
565
+ *
566
+ * MEASURED 2026-09-19 (WS-Y): that string was the entire failure report
567
+ * of the OpenImageIO deps gate for two days, and the cause was arithmetic
568
+ * -- an unconfigured OpenImageIO opens its own pool of `cpus - 1` AND
569
+ * OpenEXR's global pool of `cpus`, so it wants `2 * cpus - 1` threads out
570
+ * of a budget of `cpus`. One line here names that class of death at the
571
+ * moment it happens. Same door as the exit notices above: console.error
572
+ * plus onDiagnostic, never stdout.
573
+ */
574
+ __wasm_thread_spawn: (functionPointer, argumentPointer) => {
575
+ const tid = threadPool?.spawn(Number(functionPointer), Number(argumentPointer), signalHost.mask) ?? -11;
576
+ if (tid === -11 && !threadPoolExhaustionReported) {
577
+ threadPoolExhaustionReported = true;
578
+ const notice = `[WALI] pthread_create answered -EAGAIN: all ${threadPoolSize} worker slots are in use.`
579
+ + ` The pool is the process's whole thread budget and does not grow, so this is a QUOTA, not a cpu count --`
580
+ + ` sysconf(_SC_NPROCESSORS_ONLN) answers ${systemHost.reportedCpuCount()} here and a`
581
+ + ` library that opens more than one pool from that number wants a multiple of it. Raise the program's`
582
+ + ` requirements.workers, or state the library's concurrency.`;
583
+ console.error(notice);
584
+ options.onDiagnostic?.(notice);
585
+ }
586
+ return tid;
360
587
  },
361
588
  SYS_clock_gettime: (clockId, pointer) => {
362
589
  const nowNanoseconds = clockNanoseconds(Number(clockId));
@@ -382,6 +609,7 @@ export class BrowserWaliRuntime {
382
609
  },
383
610
  ...memoryManager.imports(),
384
611
  ...systemImports,
612
+ ...signalHost.imports(),
385
613
  ...pipeImports,
386
614
  // The relay event worker is also the process-wide descriptor table for
387
615
  // epoll, eventfd, and timerfd. Root and pthread instances must share it:
@@ -392,15 +620,28 @@ export class BrowserWaliRuntime {
392
620
  ?? systemImports.SYS_ioctl(fd, request, argument),
393
621
  };
394
622
  const handlers = { ...builtins, ...options.imports };
623
+ // select(2) is the process's poll over a scratch mapping, whichever host
624
+ // composed that poll above.
625
+ let selectScratch = 0;
626
+ handlers.SYS_select ??= createSelectImport(memory, (pointer, count, timeout) => handlers.SYS_poll(pointer, count, timeout), () => (selectScratch ||= Number(memoryManager.imports().SYS_mmap(0, SELECT_SCRATCH_BYTES, 3, 0x22, -1, 0))));
395
627
  const wali = {};
396
628
  for (const descriptor of waliDescriptors) {
397
- const handler = handlers[descriptor.name]
398
- ?? (() => { throw new UnsupportedWaliImportError([descriptor.name]); });
629
+ const handler = handlers[descriptor.name] ?? unimplementedImport(descriptor.name, options.onDiagnostic);
630
+ const spent = { calls: 0, ms: 0 };
631
+ syscallTime.set(descriptor.name, spent);
399
632
  wali[descriptor.name] = (...args) => {
400
633
  options.onSyscall?.(descriptor.name, args);
401
- const result = handler(...args);
402
- options.onSyscallResult?.(descriptor.name, result);
403
- return result;
634
+ const began = performance.now();
635
+ try {
636
+ const result = handler(...args);
637
+ options.onSyscallResult?.(descriptor.name, result);
638
+ signalHost.deliver();
639
+ return result;
640
+ }
641
+ finally {
642
+ spent.calls += 1;
643
+ spent.ms += performance.now() - began;
644
+ }
404
645
  };
405
646
  }
406
647
  const env = {
@@ -413,6 +654,44 @@ export class BrowserWaliRuntime {
413
654
  wali,
414
655
  env,
415
656
  });
657
+ if (options.forkState) {
658
+ bytes().set(new Uint8Array(options.forkState.memory));
659
+ for (const [name, value] of Object.entries(options.forkState.globals)) {
660
+ const global = instance.exports[name];
661
+ if (!(global instanceof WebAssembly.Global))
662
+ throw new Error(`Fork snapshot global ${name} is not exported.`);
663
+ global.value = value;
664
+ }
665
+ memoryManager.restoreFork(options.forkState.mappings);
666
+ systemImports.SYS_umask(options.forkState.umask);
667
+ }
668
+ else if (typeof instance.exports.asyncify_start_unwind === "function") {
669
+ const stack = instance.exports.__stack_pointer;
670
+ if (!(stack instanceof WebAssembly.Global))
671
+ throw new Error("Asyncify fork requires --export=__stack_pointer at link.");
672
+ // Reserve the module's declared stack capacity outside its initial image,
673
+ // before brk is initialized. The guest heap begins after this reservation.
674
+ const capacity = Number(stack.value);
675
+ if (capacity <= 0)
676
+ throw new Error("Asyncify fork requires a nonempty module stack.");
677
+ asyncifyData = memory().buffer.byteLength;
678
+ memory().grow(Math.ceil((capacity + 8) / 65_536));
679
+ view().setUint32(asyncifyData + 4, asyncifyData + 8 + capacity, true);
680
+ }
681
+ if (options.codec?.version === 1) {
682
+ try {
683
+ codecCoordinator = await WaliCodecCoordinator.create(memory());
684
+ codecClient = new WaliCodecClient(codecCoordinator.createChannel());
685
+ }
686
+ catch (error) {
687
+ // A host without WebCodecs (or without workers) answers the six
688
+ // imports -95, the ABI's answer for a codec the browser cannot do.
689
+ // It never takes the program down.
690
+ console.warn("[WALI] the codec bridge is unavailable:", error);
691
+ codecCoordinator = undefined;
692
+ codecClient = undefined;
693
+ }
694
+ }
416
695
  if (threaded) {
417
696
  if (!importedMemory) {
418
697
  throw new Error("A threaded WALI module must be linked with --import-memory so browser workers can share its process memory.");
@@ -424,34 +703,86 @@ export class BrowserWaliRuntime {
424
703
  sharedMmapRegistry,
425
704
  fatalState: pthreadFatal.buffer,
426
705
  imports: descriptors,
427
- size: options.threadPoolSize ?? 18,
706
+ size: threadPoolSize,
428
707
  pid,
429
708
  executablePath,
430
709
  argv,
431
710
  cwd: fileDescriptors.currentWorkingDirectory(),
432
711
  stdioAliasRegistry: stdioAliasRegistry.buffer,
433
712
  sharedPipes: sharedPipeState,
713
+ signals: signalState,
434
714
  childPidAllocator: nextChildPid.buffer,
715
+ ...(inheritedFilesystemPolicy ? { inheritedFilesystemPolicy } : {}),
435
716
  ...(options.browserProcessPorts ? { browserProcessPorts: options.browserProcessPorts } : {}),
717
+ ...(options.browserProcess?.filesystemPolicyVersion === 1 ? { childFilesystemPolicyVersion: 1 } : {}),
436
718
  ...(options.threadOutputPorts ? { outputPorts: options.threadOutputPorts } : {}),
437
719
  ...(stdin instanceof SharedWaliStdinReader ? { stdin: stdin.buffer } : {}),
720
+ terminal: options.terminal === true,
438
721
  onStdout: (chunk) => { emit(1, chunk); },
439
722
  onStderr: (chunk) => { emit(2, chunk); },
440
723
  ...(networkCoordinator ? { network: networkCoordinator } : {}),
441
- ...(filesystemCoordinator ? { filesystem: filesystemCoordinator } : {}),
724
+ ...(sharedFileClient ? { filesystem: sharedFileClient } : {}),
725
+ ...(codecCoordinator ? { codec: codecCoordinator } : {}),
442
726
  });
443
727
  await threadPool.initialize();
444
728
  }
445
729
  const start = instance.exports._start;
446
730
  if (typeof start !== "function")
447
731
  throw new Error("WALI module does not export _start.");
732
+ startEnteredAt = performance.now();
448
733
  let exitCode = 0;
734
+ let replaced = false;
449
735
  try {
450
736
  try {
451
- start();
737
+ if (rewinding)
738
+ asyncify("asyncify_start_rewind", asyncifyData);
739
+ for (;;) {
740
+ start();
741
+ if (!unwinding)
742
+ break;
743
+ asyncify("asyncify_stop_unwind");
744
+ unwinding = false;
745
+ const layout = options.memoryLayout ?? (!(source instanceof WebAssembly.Module)
746
+ ? findImportedMemory(source instanceof Uint8Array ? source : new Uint8Array(source)) : undefined);
747
+ if (!layout)
748
+ throw new Error("Fork requires the module's imported memory layout.");
749
+ const globals = {};
750
+ for (const [name, value] of Object.entries(instance.exports)) {
751
+ if (!(value instanceof WebAssembly.Global))
752
+ continue;
753
+ try {
754
+ value.value = value.value;
755
+ globals[name] = value.value;
756
+ }
757
+ catch { /* Immutable globals are reconstructed by instantiation. */ }
758
+ }
759
+ const control = new SharedArrayBuffer(8 * Int32Array.BYTES_PER_ELEMENT);
760
+ const response = new SharedArrayBuffer(BROWSER_PROCESS_RESPONSE_BYTES);
761
+ forkResult = Atomics.add(nextChildPid, 0, 1);
762
+ const registered = networkHost.registerChild(forkResult, { control, response });
763
+ if (registered < 0)
764
+ throw new Error(`Fork child registration failed: ${registered}`);
765
+ const mask = Number(systemImports.SYS_umask(0));
766
+ systemImports.SYS_umask(mask);
767
+ options.onDiagnostic?.(`[WALI] fork snapshot: ${memory().buffer.byteLength} bytes, Asyncify stack ${view().getUint32(asyncifyData, true) - asyncifyData - 8} bytes.`);
768
+ options.browserProcess.fork({
769
+ module, memoryLayout: layout, pid: forkResult, control, response,
770
+ descriptorTable: sharedFileClient.copyForForkChannel(),
771
+ childPidAllocator: nextChildPid.buffer,
772
+ argv, env: { ...options.env }, cwd: currentCwd(), executablePath,
773
+ ...(filesystemPolicy ? { filesystemPolicy } : {}),
774
+ forkState: { control, response, memory: bytes().slice().buffer, globals,
775
+ allocator: memoryManager.allocatorControl().slice(0), mappings: memoryManager.forkSnapshot(),
776
+ asyncifyData, umask: mask, parentPid: pid },
777
+ });
778
+ rewinding = true;
779
+ asyncify("asyncify_start_rewind", asyncifyData);
780
+ }
452
781
  }
453
782
  catch (error) {
454
- if (error instanceof WaliProcessExit || error instanceof WaliPthreadProcessExit)
783
+ if (error instanceof WaliProcessReplaced)
784
+ replaced = true;
785
+ else if (error instanceof WaliProcessExit || error instanceof WaliPthreadProcessExit)
455
786
  exitCode = error.code;
456
787
  else if (error instanceof UnsupportedWaliImportError)
457
788
  throw error;
@@ -467,17 +798,44 @@ export class BrowserWaliRuntime {
467
798
  finally {
468
799
  threadPool?.terminate();
469
800
  networkCoordinator?.terminate();
470
- syncFilesystem();
801
+ codecCoordinator?.terminate();
802
+ if (environmentFile && runtimeFilesystem?.existsSync(environmentFile))
803
+ runtimeFilesystem.unlinkSync(environmentFile);
804
+ if (filesystemCoordinator && options.filesystemUpdatePort && options.filesystem?.replaceSnapshot) {
805
+ options.filesystem.replaceSnapshot(filesystemCoordinator.finalSnapshot());
806
+ options.onFilesystemFinalized?.();
807
+ }
808
+ else
809
+ syncFilesystem();
810
+ sharedFileClient?.closeProcess();
811
+ if (!sharedFileClient)
812
+ fileDescriptors.descriptors.exit();
471
813
  filesystemCoordinator?.terminate();
472
814
  }
815
+ const wallMs = performance.now() - runStartedAt;
816
+ if (wallMs > PROFILE_AFTER_MS) {
817
+ const calls = [...syscallTime].filter(([, spent]) => spent.calls > 0);
818
+ const inSyscalls = calls.reduce((sum, [, spent]) => sum + spent.ms, 0);
819
+ const top = calls.sort((a, b) => b[1].ms - a[1].ms).slice(0, 8)
820
+ .map(([name, spent]) => `${name.replace(/^SYS_/u, "")} ${spent.calls}x ${Math.round(spent.ms)}ms`);
821
+ console.log("[wali-profile]", JSON.stringify({
822
+ argv: argv.slice(0, 3), wallMs: Math.round(wallMs), setupMs: Math.round(startEnteredAt - runStartedAt),
823
+ guestMs: Math.round(performance.now() - startEnteredAt), syscallMs: Math.round(inSyscalls), top,
824
+ }));
825
+ }
473
826
  return {
474
827
  exitCode,
828
+ ...(replaced ? { replaced } : {}),
475
829
  stdout: decoder.decode(concatenate(stdout)),
476
830
  stderr: decoder.decode(concatenate(stderr)),
477
831
  imports: descriptors,
478
832
  };
479
833
  }
480
834
  }
835
+ /** The calls through which a process makes a child: fork, exec, the browser's spawn door, and the wait for one. */
836
+ const CHILD_PROCESS_SYSCALLS = new Set(["SYS_fork", "SYS_execve", "SYS_browser_spawn", "SYS_browser_spawn_confined_v1", "SYS_wait4"]);
837
+ /** A run longer than this prints where its time went (`[wali-profile]`). */
838
+ const PROFILE_AFTER_MS = 2000;
481
839
  function normalizePid(pid) {
482
840
  if (pid === undefined)
483
841
  return 2;
@@ -507,8 +865,7 @@ function clockNanoseconds(clockId) {
507
865
  return BigInt(Math.floor(milliseconds * 1_000_000));
508
866
  }
509
867
  if (clockId === 1 || clockId === 4) {
510
- const milliseconds = typeof performance === "undefined" ? Date.now() : performance.now();
511
- return BigInt(Math.floor(milliseconds * 1_000_000));
868
+ return BigInt(Math.floor(monotonicMilliseconds() * 1_000_000));
512
869
  }
513
870
  return undefined;
514
871
  }