@volter/browser-wali 0.1.4 → 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 +21 -7
@@ -1,11 +1,18 @@
1
+ import { 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 { WaliMemoryManager } from "./memory.js";
2
5
  import { WaliNetworkHost } from "./network.js";
3
6
  import { SharedWaliStdinReader } from "./stdin.js";
4
- import { WaliSystemHost } from "./system.js";
7
+ import { futexRequeue, WaliSystemHost } from "./system.js";
5
8
  import { WaliFileDescriptorTable } from "./filesystem.js";
6
9
  import { WaliFilesystemClient, WaliSharedFileDescriptorHost } from "./filesystem-remote.js";
10
+ import { createWaliCodecImports, WaliCodecClient } from "./codec-remote.js";
11
+ import { createSelectImport, SELECT_SCRATCH_BYTES } from "./select.js";
12
+ import { WALI_ENOSYS, waliUnimplementedTraps } from "./capabilities.js";
7
13
  import { WaliSharedPipeHost } from "./shared-pipes.js";
8
- import { BROWSER_PROCESS_RESPONSE_BYTES, applyProcessFilesystemPatch, closeSharedPipe, decodeProcessSnapshot, retainSharedPipe, } from "./process-bridge.js";
14
+ import { BROWSER_PROCESS_RESPONSE_BYTES, closeSharedPipe, decodeProcessSnapshot, retainSharedPipe, } from "./process-bridge.js";
15
+ import { monotonicMilliseconds } from "./monotonic-clock.js";
9
16
  class ThreadExit extends Error {
10
17
  }
11
18
  const scope = globalThis;
@@ -21,14 +28,22 @@ let mmapCursor;
21
28
  let sharedMmapRegistry;
22
29
  let fatalState;
23
30
  let networkChannel;
31
+ let networkHost;
32
+ let signalState;
33
+ let signalHost;
24
34
  let filesystemChannel;
35
+ let codecChannel;
25
36
  let stdin;
37
+ let terminalAttached = false;
26
38
  let stdioAliasRegistry;
27
39
  let sharedPipes;
28
40
  let childPidAllocator;
29
41
  let browserProcessPort;
42
+ let childFilesystemPolicyVersion;
43
+ let inheritedFilesystemPolicy;
30
44
  let outputPort;
31
45
  let slotIndex = -1;
46
+ let poolSize = 1;
32
47
  let coordinatorPort;
33
48
  let diagnosticLength;
34
49
  let diagnosticBytes;
@@ -39,6 +54,27 @@ const reportedUnsupportedImports = new Set();
39
54
  const sleepWord = new Int32Array(new SharedArrayBuffer(Int32Array.BYTES_PER_ELEMENT));
40
55
  const diagnosticHistory = [];
41
56
  const DIAGNOSTIC_HISTORY_LIMIT = 32;
57
+ /**
58
+ * THE DIAGNOSTIC RING. A pthread records every syscall it makes (the call and
59
+ * its result) so that a fatal can print the thread's last moments. It used to
60
+ * rebuild the whole history on EVERY write -- join 32 lines, encode them,
61
+ * fill(0) the 16 KiB shared buffer, copy -- twice per syscall, on threads
62
+ * whose work IS syscalls (oneTBB workers park and wake on futex). Measured
63
+ * 2026-09-20: the courtyard's depsgraph updates ran 1.3-1.7x and its mesh
64
+ * copies 2-3x behind the Emscripten skew while the booleans themselves were
65
+ * at parity. Now each write encodes ONE line into a fixed slot of the ring
66
+ * (`encodeInto`, no allocation) and bumps a counter; `threads.ts` reads the
67
+ * last DIAGNOSTIC_HISTORY_LIMIT slots in order when it needs them.
68
+ * Layout, shared with `WaliThreadPool.diagnostics`: Int32 [0] = lines written
69
+ * (monotonic); slot i at DIAGNOSTIC_SLOT_OFFSET + i * DIAGNOSTIC_SLOT_BYTES,
70
+ * a little-endian u16 length then the UTF-8 bytes, truncated to fit.
71
+ */
72
+ const DIAGNOSTIC_SLOT_BYTES = 480;
73
+ const DIAGNOSTIC_SLOT_OFFSET = Int32Array.BYTES_PER_ELEMENT;
74
+ const diagnosticEncoder = new TextEncoder();
75
+ // `encodeInto` refuses a view over shared memory; the line is encoded into
76
+ // this private scratch and copied into the slot.
77
+ const diagnosticScratch = new Uint8Array(DIAGNOSTIC_SLOT_BYTES - 2);
42
78
  const DETACHED_THREAD_RUNTIME_NOTICE = new TextEncoder().encode("== Detached Thread End: unmapself TBD");
43
79
  scope.onmessage = (event) => {
44
80
  if (event.data.kind === "initialize") {
@@ -53,6 +89,8 @@ scope.onmessage = (event) => {
53
89
  };
54
90
  function runThread(message) {
55
91
  threadId = message.tid;
92
+ if (signalHost)
93
+ signalHost.mask = message.signalMask ?? 0n;
56
94
  diagnosticHistory.length = 0;
57
95
  writeDiagnostic(`tid ${threadId} started function ${message.functionPointer}`);
58
96
  try {
@@ -111,18 +149,24 @@ async function initialize(message) {
111
149
  sharedPipes = message.sharedPipes;
112
150
  childPidAllocator = new Int32Array(message.childPidAllocator);
113
151
  browserProcessPort = message.browserProcessPort;
152
+ childFilesystemPolicyVersion = message.childFilesystemPolicyVersion;
153
+ inheritedFilesystemPolicy = message.inheritedFilesystemPolicy === undefined ? undefined : normalizeProcessFilesystemPolicy(message.inheritedFilesystemPolicy);
114
154
  browserProcessPort?.start();
115
155
  outputPort = message.outputPort;
116
156
  outputPort?.start();
117
157
  slotIndex = message.slotIndex;
158
+ poolSize = message.poolSize;
118
159
  coordinatorPort = message.coordinatorPort;
119
160
  coordinatorPort.onmessage = (event) => runThread(event.data);
120
161
  coordinatorPort.start();
121
162
  diagnosticLength = new Int32Array(message.diagnostic, 0, 1);
122
163
  diagnosticBytes = new Uint8Array(message.diagnostic, Int32Array.BYTES_PER_ELEMENT);
123
164
  stdin = message.stdin ? new SharedWaliStdinReader(message.stdin) : undefined;
165
+ terminalAttached = message.terminal === true;
124
166
  networkChannel = message.network;
167
+ signalState = message.signals;
125
168
  filesystemChannel = message.filesystem;
169
+ codecChannel = message.codec;
126
170
  const imports = createImports();
127
171
  instance = await WebAssembly.instantiate(message.module, imports);
128
172
  scope.postMessage({ kind: "ready" });
@@ -134,26 +178,41 @@ async function initialize(message) {
134
178
  function createImports() {
135
179
  const wali = {};
136
180
  const handlers = threadHandlers();
181
+ let selectScratch = 0;
182
+ handlers.SYS_select ??= createSelectImport(requiredMemory, (pointer, count, timeout) => handlers.SYS_poll(pointer, count, timeout), () => (selectScratch ||= Number(handlers.SYS_mmap(0, SELECT_SCRATCH_BYTES, 3, 0x22, -1, 0))));
137
183
  if (networkChannel) {
138
184
  const fallback = { ...handlers };
139
- Object.assign(handlers, new WaliNetworkHost(requiredMemory, networkChannel).imports(fallback));
185
+ Object.assign(handlers, networkHost.imports(fallback));
140
186
  }
141
187
  for (const descriptor of descriptors) {
142
188
  if (descriptor.module !== "wali" || descriptor.kind !== "function")
143
189
  continue;
144
- const handler = handlers[descriptor.name] ?? (() => {
145
- if (!reportedUnsupportedImports.has(descriptor.name)) {
146
- reportedUnsupportedImports.add(descriptor.name);
147
- console.error(`[WALI pthread ${threadId}] unsupported import: ${descriptor.name}`);
148
- }
149
- return -38n;
150
- });
190
+ // The SAME question the root instance asks (`runtime.ts`'s
191
+ // `unimplementedImport`), from the same file: -ENOSYS where a caller can
192
+ // act on it, a trap for the four names where it cannot. This half already
193
+ // answered -38n for everything while the root killed the process for
194
+ // everything, so whether a program survived an unimplemented syscall
195
+ // depended on WHICH THREAD made the call.
196
+ const handler = handlers[descriptor.name] ?? (waliUnimplementedTraps(descriptor.name)
197
+ ? () => { throw new Error(`Unsupported WALI imports: ${descriptor.name}`); }
198
+ : () => {
199
+ if (!reportedUnsupportedImports.has(descriptor.name)) {
200
+ reportedUnsupportedImports.add(descriptor.name);
201
+ const notice = `[WALI pthread ${threadId}] ${descriptor.name} is not implemented; answering -ENOSYS (38) as a kernel would.`;
202
+ console.error(notice);
203
+ // The same door the root instance uses, over the port this thread
204
+ // already holds: a notice from a pthread is no less the host's.
205
+ outputPort?.postMessage({ type: "diagnostic", message: notice, tid: threadId });
206
+ }
207
+ return WALI_ENOSYS;
208
+ });
151
209
  wali[descriptor.name] = (...args) => {
152
210
  const call = `${descriptor.name}(${args.map(String).join(",")})`;
153
211
  writeDiagnostic(call);
154
212
  try {
155
213
  const result = handler(...args);
156
214
  writeDiagnostic(`${call} => ${String(result)}`);
215
+ signalHost?.deliver();
157
216
  return result;
158
217
  }
159
218
  catch (error) {
@@ -173,7 +232,7 @@ function createImports() {
173
232
  };
174
233
  }
175
234
  function threadHandlers() {
176
- const systemImports = new WaliSystemHost(requiredMemory, processPid, stdin, fatalState).imports();
235
+ const systemImports = new WaliSystemHost(requiredMemory, processPid, stdin, fatalState, terminalAttached, poolSize).imports();
177
236
  const ioctl = systemImports.SYS_ioctl;
178
237
  const filesystemClient = filesystemChannel ? new WaliFilesystemClient(filesystemChannel) : undefined;
179
238
  const fileDescriptors = filesystemChannel ? new WaliFileDescriptorTable({
@@ -189,21 +248,100 @@ function threadHandlers() {
189
248
  return BigInt(value.byteLength);
190
249
  },
191
250
  ...(stdioAliasRegistry ? { stdioAliasRegistry } : {}),
251
+ ...(sharedPipes ? { descriptorState: sharedPipes } : {}),
192
252
  specialDescriptorMinimum: 10_000,
193
253
  descriptorMaximum: 19_999,
194
254
  }) : undefined;
195
255
  const localFileImports = fileDescriptors?.imports() ?? {};
196
256
  const sharedFileHost = filesystemClient
197
- ? new WaliSharedFileDescriptorHost(filesystemClient, stdioAliasRegistry) : undefined;
257
+ ? new WaliSharedFileDescriptorHost(filesystemClient, stdioAliasRegistry, sharedPipes) : undefined;
198
258
  const memoryManager = new WaliMemoryManager(requiredMemory, (fd, offset, length) => sharedFileHost?.readForMapping(fd, offset, length)
199
259
  ?? fileDescriptors?.readForMapping(fd, offset, length), requiredMmapCursor(), (fd) => sharedFileHost?.mappingPath(fd) ?? fileDescriptors?.mappingPath(fd), (path, offset, value) => sharedFileHost?.writeForMapping(path, offset, value)
200
- ?? fileDescriptors?.writeForMapping(path, offset, value) ?? false, requiredSharedMmapRegistry());
260
+ ?? fileDescriptors?.writeForMapping(path, offset, value) ?? false, requiredSharedMmapRegistry(), (message) => { outputPort?.postMessage({ type: "diagnostic", message, tid: threadId }); });
261
+ signalHost = signalState ? new WaliSignalHost(new WaliSignals(signalState), requiredMemory, () => instance?.exports.__indirect_function_table, () => Number(memoryManager.imports().SYS_mmap(0, 4096, 3, 0x22, -1, 0))) : undefined;
262
+ networkHost = networkChannel ? new WaliNetworkHost(requiredMemory, networkChannel, signalHost) : undefined;
201
263
  const fileImports = sharedFileHost?.imports(localFileImports) ?? localFileImports;
202
- const sharedPipeHost = sharedPipes ? new WaliSharedPipeHost(requiredMemory, sharedPipes) : undefined;
264
+ const sharedPipeHost = sharedPipes ? new WaliSharedPipeHost(requiredMemory, sharedPipes, undefined, filesystemClient) : undefined;
203
265
  const pipeImports = sharedPipeHost?.imports(fileImports) ?? fileImports;
204
- const childProcesses = new Map();
266
+ const spawnBrowserProcess = (args, filesystemPolicy) => {
267
+ const [pathname, argvPointer, envPointer, cwdPointer, stdinFd, stdoutFd, stderrFd] = args;
268
+ if (!browserProcessPort || !childPidAllocator || !sharedPipeHost || !filesystemClient || !networkHost)
269
+ return -38;
270
+ if (inheritedFilesystemPolicy) {
271
+ if (childFilesystemPolicyVersion !== 1)
272
+ return -95;
273
+ filesystemPolicy = intersectProcessFilesystemPolicies(inheritedFilesystemPolicy, filesystemPolicy);
274
+ }
275
+ const executablePath = readCString(Number(pathname));
276
+ const argv = readCStringArray(Number(argvPointer));
277
+ if (argv.length === 0)
278
+ argv.push(executablePath);
279
+ const env = {};
280
+ for (const entry of readCStringArray(Number(envPointer))) {
281
+ const separator = entry.indexOf("=");
282
+ if (separator > 0)
283
+ env[entry.slice(0, separator)] = entry.slice(separator + 1);
284
+ }
285
+ const childPid = Atomics.add(childPidAllocator, 0, 1);
286
+ const process = {
287
+ control: new SharedArrayBuffer(8 * Int32Array.BYTES_PER_ELEMENT),
288
+ response: new SharedArrayBuffer(BROWSER_PROCESS_RESPONSE_BYTES),
289
+ snapshotApplied: false,
290
+ };
291
+ const exportPipe = (fd, role) => {
292
+ const handle = sharedPipeHost.exportBrowserProcessPipe(fd);
293
+ return handle?.role === role ? retainSharedPipe(handle) : undefined;
294
+ };
295
+ const stdin = exportPipe(Number(stdinFd), "reader");
296
+ const stdout = exportPipe(Number(stdoutFd), "writer");
297
+ const stderr = exportPipe(Number(stderrFd), "writer");
298
+ try {
299
+ const registered = networkHost.registerChild(childPid, process);
300
+ if (registered < 0)
301
+ throw new Error(`Child registration failed: ${registered}`);
302
+ const message = {
303
+ type: "spawn",
304
+ pid: childPid,
305
+ parentPid: processPid,
306
+ childPidAllocator: childPidAllocator.buffer,
307
+ descriptorTable: filesystemClient.copyForExecChannel([Number(stdinFd), Number(stdoutFd), Number(stderrFd)]),
308
+ ...(filesystemPolicy ? { filesystemPolicy } : {}),
309
+ executablePath,
310
+ argv,
311
+ env,
312
+ ...(Number(envPointer) === 0 ? { inheritEnvironment: true } : {}),
313
+ cwd: Number(cwdPointer) === 0 ? processCwd : readCString(Number(cwdPointer)),
314
+ control: process.control,
315
+ response: process.response,
316
+ // Mutations are already mirrored continuously by the process
317
+ // filesystem coordinator; helper launch must not serialize the VFS.
318
+ filesystemPatch: { files: [], removed: [] },
319
+ ...(stdin ? { stdin } : {}),
320
+ ...(stdout ? { stdout } : {}),
321
+ ...(stderr ? { stderr } : {}),
322
+ };
323
+ filesystemClient.prepareSpawn();
324
+ browserProcessPort.postMessage(message, [message.descriptorTable.port, message.descriptorTable.releasePort]);
325
+ return childPid;
326
+ }
327
+ catch {
328
+ networkHost.forgetChild(childPid);
329
+ if (stdin)
330
+ closeSharedPipe(stdin);
331
+ if (stdout)
332
+ closeSharedPipe(stdout);
333
+ if (stderr)
334
+ closeSharedPipe(stderr);
335
+ return -5;
336
+ }
337
+ };
338
+ // A pthread holds its own channel to the process's one codec worker. No
339
+ // channel means the process did not negotiate the bridge: every codec
340
+ // import answers -95 before an argument is read.
341
+ const codecClient = codecChannel ? new WaliCodecClient(codecChannel) : undefined;
205
342
  return {
206
343
  ...systemImports,
344
+ ...createWaliCodecImports(requiredMemory, () => codecClient !== undefined, () => codecClient),
207
345
  __init: () => 0,
208
346
  __deinit: () => 0,
209
347
  __proc_exit: () => { throw new ThreadExit(); },
@@ -219,7 +357,42 @@ function threadHandlers() {
219
357
  },
220
358
  __wasm_thread_spawn: (functionPointer, argumentPointer) => spawnNestedThread(Number(functionPointer), Number(argumentPointer)),
221
359
  SYS_exit: () => { throw new ThreadExit(); },
360
+ /**
361
+ * A WORKER ENDING THE WHOLE PROCESS SAYS SO, BY THREAD.
362
+ *
363
+ * This path is how a pthread ends the process: the status goes in slot 2,
364
+ * slot 0 goes to 2, and `system.ts`'s `throwIfPthreadFailed` turns that
365
+ * into `WaliPthreadProcessExit`, which `runtime.ts` makes the process exit
366
+ * code. The host then sees a clean `exited <status>` with NOTHING naming
367
+ * the thread that did it -- and a status of 0 reads as an orderly
368
+ * shutdown, which is the most misleading answer this runtime can give.
369
+ *
370
+ * MEASURED 2026-09-19 (WS-W), and it cost a battery: headless Blender on
371
+ * this runtime ended mid-call twice -- `17-workshop-interior` at its
372
+ * seq 19, `19-tram-stop` at its seq 28, both immediately after a Cycles
373
+ * render -- reported to the editor as "Blender exited 0". Blender calls
374
+ * no `exit()` there. musl does: `wali-musl/src/thread/pthread_create.c`,
375
+ * `__pthread_exit`, "if this is the only thread in the list, don't
376
+ * proceed with termination of the thread ... exit(0)". A departing worker
377
+ * that finds `self->next == self` ends the PROCESS. So the question a
378
+ * reader needs answered first is WHICH THREAD CALLED THIS, and until this
379
+ * line existed nothing anywhere recorded it.
380
+ *
381
+ * The notice is the same door `runtime.ts` uses for the unimplemented
382
+ * syscall: `console.error` for devtools and `{type:"diagnostic"}` over
383
+ * the port this thread already holds, so it reaches the host's
384
+ * `onDiagnostic` and the editor's console rather than only a nested
385
+ * worker's devtools. It is not stdout: a kernel does not write to your
386
+ * streams.
387
+ */
222
388
  SYS_exit_group: (status) => {
389
+ const notice = `[WALI pthread ${threadId}] SYS_exit_group(${Number(status) & 0xff})`
390
+ + ` -- a WORKER thread, not the main thread, is ending the process.`
391
+ + ` If the program did not ask to exit, read musl's __pthread_exit:`
392
+ + ` a departing thread that finds itself alone on the thread list calls exit(0).`;
393
+ writeDiagnostic(notice);
394
+ console.error(notice);
395
+ outputPort?.postMessage({ type: "diagnostic", message: notice, tid: threadId });
223
396
  if (fatalState) {
224
397
  Atomics.store(fatalState, 2, Number(status) & 0xff);
225
398
  Atomics.store(fatalState, 0, 2);
@@ -247,7 +420,9 @@ function threadHandlers() {
247
420
  SYS_sigaltstack: (_next, previous) => zeroMemory(Number(previous), 12),
248
421
  SYS_mprotect: () => 0n,
249
422
  SYS_munmap: () => 0n,
250
- SYS_futex: (address, operation, value, timeout) => futex(Number(address), Number(operation), Number(value), Number(timeout)),
423
+ // All six of the kernel's arguments: FUTEX_REQUEUE reads the timeout slot
424
+ // as a count and needs `uaddr2` and `val3` as well. See futexRequeue.
425
+ SYS_futex: (address, operation, value, timeout, address2, value3) => futex(Number(address), Number(operation), Number(value), Number(timeout), Number(address2 ?? 0n), Number(value3 ?? 0n)),
251
426
  SYS_clock_gettime: (clock, pointer) => clockGettime(Number(clock), Number(pointer)),
252
427
  SYS_clock_nanosleep: (clock, flags, request, remaining) => clockNanosleep(Number(clock), Number(flags), Number(request), Number(remaining)),
253
428
  SYS_nanosleep: (request, remaining) => clockNanosleep(1, 0, Number(request), Number(remaining)),
@@ -255,75 +430,27 @@ function threadHandlers() {
255
430
  SYS_epoll_pwait: (_fd, _events, _maximum, timeout) => idlePoll(Number(timeout)),
256
431
  SYS_gettimeofday: (pointer) => gettimeofday(Number(pointer)),
257
432
  SYS_getrandom: (pointer, length) => getrandom(Number(pointer), Number(length)),
258
- SYS_browser_spawn: (pathname, argvPointer, envPointer, cwdPointer, stdinFd, stdoutFd, stderrFd) => {
259
- if (!browserProcessPort || !childPidAllocator || !sharedPipeHost || !filesystemClient)
260
- return -38;
261
- const executablePath = readCString(Number(pathname));
262
- const argv = readCStringArray(Number(argvPointer));
263
- if (argv.length === 0)
264
- argv.push(executablePath);
265
- const env = {};
266
- for (const entry of readCStringArray(Number(envPointer))) {
267
- const separator = entry.indexOf("=");
268
- if (separator > 0)
269
- env[entry.slice(0, separator)] = entry.slice(separator + 1);
270
- }
271
- const childPid = Atomics.add(childPidAllocator, 0, 1);
272
- const process = {
273
- control: new SharedArrayBuffer(8 * Int32Array.BYTES_PER_ELEMENT),
274
- response: new SharedArrayBuffer(BROWSER_PROCESS_RESPONSE_BYTES),
275
- snapshotApplied: false,
276
- };
277
- const exportPipe = (fd, role) => {
278
- const handle = sharedPipeHost.exportBrowserProcessPipe(fd);
279
- return handle?.role === role ? retainSharedPipe(handle) : undefined;
280
- };
281
- const stdin = exportPipe(Number(stdinFd), "reader");
282
- const stdout = exportPipe(Number(stdoutFd), "writer");
283
- const stderr = exportPipe(Number(stderrFd), "writer");
284
- childProcesses.set(childPid, process);
285
- try {
286
- const message = {
287
- type: "spawn",
288
- pid: childPid,
289
- executablePath,
290
- argv,
291
- env,
292
- cwd: Number(cwdPointer) === 0 ? processCwd : readCString(Number(cwdPointer)),
293
- control: process.control,
294
- response: process.response,
295
- // Mutations are already mirrored continuously by the process
296
- // filesystem coordinator; helper launch must not serialize the VFS.
297
- filesystemPatch: { files: [], removed: [] },
298
- ...(stdin ? { stdin } : {}),
299
- ...(stdout ? { stdout } : {}),
300
- ...(stderr ? { stderr } : {}),
301
- };
302
- browserProcessPort.postMessage(message);
303
- return childPid;
304
- }
305
- catch {
306
- childProcesses.delete(childPid);
307
- if (stdin)
308
- closeSharedPipe(stdin);
309
- if (stdout)
310
- closeSharedPipe(stdout);
311
- if (stderr)
312
- closeSharedPipe(stderr);
313
- return -5;
314
- }
315
- },
316
- SYS_wait4: (requestedPid, status, options) => filesystemClient
317
- ? waitForChild(childProcesses, filesystemClient, Number(requestedPid), Number(status), Number(options))
433
+ SYS_browser_spawn: (...args) => spawnBrowserProcess(args),
434
+ SYS_browser_spawn_confined_v1: createConfinedWaliSpawnImport(() => memory, () => childFilesystemPolicyVersion === 1, spawnBrowserProcess),
435
+ SYS_wait4: (requestedPid, status, options) => filesystemClient && networkHost
436
+ ? waitForChild(networkHost, filesystemClient, Number(requestedPid), Number(status), Number(options), signalHost?.mask ?? 0n)
318
437
  : -38n,
319
438
  SYS_kill: (requestedPid, signal) => {
439
+ // `kill(getpid(), 0)` is a liveness probe and a thread asks it of its own
440
+ // process as the main thread does (`runtime.ts`'s `signalSelf`). It was
441
+ // answered ESRCH, and a program that probes its own pid from a worker
442
+ // thread (Volter Harness's `process_is_live`, from a tokio worker) read itself
443
+ // as dead.
444
+ if (Number(signal) === 0 && (Number(requestedPid) === 0 || Number(requestedPid) === processPid))
445
+ return 0n;
320
446
  const childPid = Math.abs(Number(requestedPid));
321
- if (!childProcesses.has(childPid) || !browserProcessPort)
447
+ if (!networkHost?.hasChild(childPid) || !browserProcessPort)
322
448
  return -3n;
323
449
  browserProcessPort.postMessage({ type: "kill", pid: childPid, signal: Number(signal) });
324
450
  return 0n;
325
451
  },
326
452
  SYS_setpgid: () => 0n,
453
+ SYS_pidfd_open: () => -38n,
327
454
  SYS_open: () => -2n,
328
455
  SYS_openat: () => -2n,
329
456
  SYS_stat: () => -2n,
@@ -350,6 +477,7 @@ function threadHandlers() {
350
477
  SYS_fcntl: (fd, command, argument) => fcntl(Number(fd), Number(command), Number(BigInt.asIntN(32, BigInt(argument)))),
351
478
  ...memoryManager.imports(),
352
479
  ...pipeImports,
480
+ ...signalHost?.imports(),
353
481
  SYS_ioctl: (fd, request, argument) => sharedPipeHost?.ioctl(Number(fd), Number(request))
354
482
  ?? fileDescriptors?.ioctl(Number(fd), Number(request))
355
483
  ?? ioctl(stdioDescriptor(Number(fd)) ?? Number(fd), request, argument),
@@ -362,20 +490,24 @@ function spawnNestedThread(functionPointer, argumentPointer) {
362
490
  if (!coordinatorPort)
363
491
  return -11;
364
492
  const response = new Int32Array(new SharedArrayBuffer(2 * Int32Array.BYTES_PER_ELEMENT));
365
- coordinatorPort.postMessage({ kind: "spawn", functionPointer, argumentPointer, response: response.buffer });
493
+ coordinatorPort.postMessage({ kind: "spawn", functionPointer, argumentPointer, signalMask: signalHost?.mask ?? 0n, response: response.buffer });
366
494
  Atomics.wait(response, 0, 0);
367
495
  return Atomics.load(response, 1);
368
496
  }
369
497
  function writeDiagnostic(message) {
370
498
  if (!diagnosticLength || !diagnosticBytes)
371
499
  return;
500
+ const written = Atomics.load(diagnosticLength, 0);
501
+ const slot = written % DIAGNOSTIC_HISTORY_LIMIT;
502
+ const at = DIAGNOSTIC_SLOT_OFFSET - Int32Array.BYTES_PER_ELEMENT + slot * DIAGNOSTIC_SLOT_BYTES;
372
503
  diagnosticHistory.push(message);
373
504
  if (diagnosticHistory.length > DIAGNOSTIC_HISTORY_LIMIT)
374
505
  diagnosticHistory.shift();
375
- const encoded = new TextEncoder().encode(diagnosticHistory.join("\n")).subarray(-diagnosticBytes.byteLength);
376
- diagnosticBytes.fill(0);
377
- diagnosticBytes.set(encoded);
378
- Atomics.store(diagnosticLength, 0, encoded.byteLength);
506
+ const { written: length } = diagnosticEncoder.encodeInto(message, diagnosticScratch);
507
+ diagnosticBytes.set(diagnosticScratch.subarray(0, length), at + 2);
508
+ diagnosticBytes[at] = length & 0xff;
509
+ diagnosticBytes[at + 1] = length >>> 8;
510
+ Atomics.store(diagnosticLength, 0, written + 1);
379
511
  }
380
512
  function readlink(pathPointer, outputPointer, length) {
381
513
  if (readCString(pathPointer) !== "/proc/self/exe")
@@ -466,42 +598,18 @@ function readCStringArray(pointer) {
466
598
  }
467
599
  throw new WebAssembly.RuntimeError("WALI process string array is not terminated.");
468
600
  }
469
- function waitForChild(children, filesystem, requestedPid, statusPointer, options) {
470
- let childPid;
471
- let process;
472
- if (requestedPid === -1) {
473
- const entry = children.entries().next().value;
474
- if (!entry)
475
- return -10n;
476
- [childPid, process] = entry;
477
- }
478
- else {
479
- const exact = children.get(requestedPid);
480
- if (!exact)
481
- return -10n;
482
- childPid = requestedPid;
483
- process = exact;
484
- }
485
- const control = new Int32Array(process.control);
486
- if (Atomics.load(control, 0) === 0) {
487
- if ((options & 1) !== 0)
488
- return 0n;
489
- while (Atomics.load(control, 0) === 0)
490
- Atomics.wait(control, 0, 0, 1_000);
491
- }
492
- if (!process.snapshotApplied) {
493
- process.snapshotApplied = true;
494
- const patch = decodeProcessSnapshot(process);
495
- if (patch)
496
- applyProcessFilesystemPatch(filesystem, patch);
497
- }
498
- if (statusPointer !== 0) {
499
- if (statusPointer < 0 || statusPointer + 4 > bytes().byteLength)
500
- return -14n;
501
- view().setInt32(statusPointer, (Atomics.load(control, 1) & 0xff) << 8, true);
502
- }
503
- children.delete(childPid);
504
- return BigInt(childPid);
601
+ function waitForChild(host, filesystem, requestedPid, statusPointer, options, mask) {
602
+ if (statusPointer !== 0 && (statusPointer < 0 || statusPointer + 4 > bytes().byteLength))
603
+ return -14n;
604
+ const { result, process } = host.waitChild(requestedPid, options, mask);
605
+ if (result <= 0)
606
+ return BigInt(result);
607
+ const patch = decodeProcessSnapshot(process);
608
+ if (patch)
609
+ filesystem.importPeerPatch(patch);
610
+ if (statusPointer !== 0)
611
+ view().setInt32(statusPointer, (Atomics.load(new Int32Array(process.control), 1) & 0xff) << 8, true);
612
+ return BigInt(result);
505
613
  }
506
614
  function requiredMemory() {
507
615
  if (!memory)
@@ -525,7 +633,7 @@ function zeroMemory(pointer, length) {
525
633
  bytes().fill(0, pointer, pointer + length);
526
634
  return 0n;
527
635
  }
528
- function futex(address, operation, value, timeoutPointer) {
636
+ function futex(address, operation, value, timeoutPointer, address2 = 0, value3 = 0) {
529
637
  if (address % 4 !== 0)
530
638
  return -22n;
531
639
  const words = new Int32Array(requiredMemory().buffer);
@@ -533,6 +641,17 @@ function futex(address, operation, value, timeoutPointer) {
533
641
  const command = operation & 0x7f;
534
642
  if (command === 1 || command === 10)
535
643
  return BigInt(Atomics.notify(words, index, Math.max(0, value)));
644
+ // A pthread worker is where a missing requeue actually strands a thread: its
645
+ // wait below is a single Atomics.wait with no timeout, so a waiter that the
646
+ // kernel would have requeued and woken is parked for the life of the
647
+ // process. futexRequeue carries the whole argument.
648
+ if (command === 3 || command === 4) {
649
+ if (address2 % 4 !== 0)
650
+ return -22n;
651
+ if (address2 < 0 || address2 + 4 > requiredMemory().buffer.byteLength)
652
+ return -22n;
653
+ return futexRequeue(words, index, command, value, timeoutPointer, value3);
654
+ }
536
655
  if (command !== 0 && command !== 9)
537
656
  return -38n;
538
657
  let timeout;
@@ -543,14 +662,14 @@ function futex(address, operation, value, timeoutPointer) {
543
662
  return -22n;
544
663
  const milliseconds = Number(seconds * 1000n + nanoseconds / 1000000n);
545
664
  timeout = command === 9
546
- ? Math.max(0, milliseconds - ((operation & 0x100) !== 0 ? Date.now() : performance.now()))
665
+ ? Math.max(0, milliseconds - ((operation & 0x100) !== 0 ? Date.now() : monotonicMilliseconds()))
547
666
  : milliseconds;
548
667
  }
549
668
  const result = Atomics.wait(words, index, value, timeout);
550
669
  return result === "not-equal" ? -11n : result === "timed-out" ? -110n : 0n;
551
670
  }
552
671
  function clockGettime(clock, pointer) {
553
- const milliseconds = clock === 0 ? performance.timeOrigin + performance.now() : performance.now();
672
+ const milliseconds = clock === 0 ? performance.timeOrigin + performance.now() : monotonicMilliseconds();
554
673
  const nanoseconds = BigInt(Math.floor(milliseconds * 1_000_000));
555
674
  view().setBigInt64(pointer, nanoseconds / 1000000000n, true);
556
675
  view().setBigInt64(pointer + 8, nanoseconds % 1000000000n, true);
@@ -573,7 +692,7 @@ function clockNanosleep(clock, flags, request, remaining) {
573
692
  return -22n;
574
693
  let duration = Number(seconds * 1000n + nanoseconds / 1000000n);
575
694
  if (flags & 1)
576
- duration = Math.max(0, duration - (clock === 0 ? Date.now() : performance.now()));
695
+ duration = Math.max(0, duration - (clock === 0 ? Date.now() : monotonicMilliseconds()));
577
696
  Atomics.wait(sleepWord, 0, 0, duration);
578
697
  if (remaining !== 0)
579
698
  bytes().fill(0, remaining, remaining + 16);