@secure-exec/core 0.1.1-rc.2 → 0.2.0-rc.1

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 (102) hide show
  1. package/dist/esm-compiler.d.ts +5 -1
  2. package/dist/esm-compiler.js +5 -1
  3. package/dist/fs-helpers.d.ts +1 -1
  4. package/dist/generated/isolate-runtime.d.ts +15 -15
  5. package/dist/generated/isolate-runtime.js +15 -15
  6. package/dist/index.d.ts +25 -6
  7. package/dist/index.js +23 -3
  8. package/dist/isolate-runtime/apply-custom-global-policy.js +3 -3
  9. package/dist/isolate-runtime/apply-timing-mitigation-freeze.js +10 -8
  10. package/dist/isolate-runtime/apply-timing-mitigation-off.js +2 -2
  11. package/dist/isolate-runtime/bridge-attach.js +2 -2
  12. package/dist/isolate-runtime/bridge-initial-globals.js +3 -3
  13. package/dist/isolate-runtime/eval-script-result.js +1 -1
  14. package/dist/isolate-runtime/global-exposure-helpers.js +2 -2
  15. package/dist/isolate-runtime/init-commonjs-module-globals.js +2 -2
  16. package/dist/isolate-runtime/override-process-cwd.js +1 -1
  17. package/dist/isolate-runtime/override-process-env.js +1 -1
  18. package/dist/isolate-runtime/require-setup.js +2236 -19
  19. package/dist/isolate-runtime/set-commonjs-file-globals.js +2 -2
  20. package/dist/isolate-runtime/set-stdin-data.js +1 -1
  21. package/dist/isolate-runtime/setup-dynamic-import.js +47 -15
  22. package/dist/isolate-runtime/setup-fs-facade.js +2 -2
  23. package/dist/kernel/command-registry.d.ts +44 -0
  24. package/dist/kernel/command-registry.js +114 -0
  25. package/dist/kernel/device-layer.d.ts +12 -0
  26. package/dist/kernel/device-layer.js +262 -0
  27. package/dist/kernel/dns-cache.d.ts +29 -0
  28. package/dist/kernel/dns-cache.js +52 -0
  29. package/dist/kernel/fd-table.d.ts +84 -0
  30. package/dist/kernel/fd-table.js +278 -0
  31. package/dist/kernel/file-lock.d.ts +34 -0
  32. package/dist/kernel/file-lock.js +123 -0
  33. package/dist/kernel/host-adapter.d.ts +50 -0
  34. package/dist/kernel/host-adapter.js +8 -0
  35. package/dist/kernel/index.d.ts +36 -0
  36. package/dist/kernel/index.js +34 -0
  37. package/dist/kernel/inode-table.d.ts +43 -0
  38. package/dist/kernel/inode-table.js +85 -0
  39. package/dist/kernel/kernel.d.ts +9 -0
  40. package/dist/kernel/kernel.js +1396 -0
  41. package/dist/kernel/permissions.d.ts +27 -0
  42. package/dist/kernel/permissions.js +118 -0
  43. package/dist/kernel/pipe-manager.d.ts +64 -0
  44. package/dist/kernel/pipe-manager.js +267 -0
  45. package/dist/kernel/proc-layer.d.ts +11 -0
  46. package/dist/kernel/proc-layer.js +501 -0
  47. package/dist/kernel/process-table.d.ts +124 -0
  48. package/dist/kernel/process-table.js +631 -0
  49. package/dist/kernel/pty.d.ts +108 -0
  50. package/dist/kernel/pty.js +541 -0
  51. package/dist/kernel/socket-table.d.ts +305 -0
  52. package/dist/kernel/socket-table.js +1124 -0
  53. package/dist/kernel/timer-table.d.ts +54 -0
  54. package/dist/kernel/timer-table.js +108 -0
  55. package/dist/kernel/types.d.ts +500 -0
  56. package/dist/kernel/types.js +89 -0
  57. package/dist/kernel/user.d.ts +29 -0
  58. package/dist/kernel/user.js +35 -0
  59. package/dist/kernel/vfs.d.ts +54 -0
  60. package/dist/kernel/vfs.js +8 -0
  61. package/dist/kernel/wait.d.ts +45 -0
  62. package/dist/kernel/wait.js +112 -0
  63. package/dist/kernel/wstatus.d.ts +21 -0
  64. package/dist/kernel/wstatus.js +33 -0
  65. package/dist/module-resolver.d.ts +4 -0
  66. package/dist/module-resolver.js +4 -0
  67. package/dist/package-bundler.d.ts +6 -1
  68. package/dist/runtime-driver.d.ts +3 -1
  69. package/dist/shared/bridge-contract.d.ts +529 -94
  70. package/dist/shared/bridge-contract.js +86 -3
  71. package/dist/shared/console-formatter.js +4 -0
  72. package/dist/shared/global-exposure.js +345 -0
  73. package/dist/shared/in-memory-fs.d.ts +30 -11
  74. package/dist/shared/in-memory-fs.js +383 -109
  75. package/dist/shared/permissions.d.ts +4 -6
  76. package/dist/shared/permissions.js +24 -28
  77. package/dist/types.d.ts +20 -130
  78. package/dist/types.js +5 -0
  79. package/package.json +12 -22
  80. package/dist/bridge/active-handles.d.ts +0 -22
  81. package/dist/bridge/active-handles.js +0 -55
  82. package/dist/bridge/child-process.d.ts +0 -99
  83. package/dist/bridge/child-process.js +0 -656
  84. package/dist/bridge/fs.d.ts +0 -281
  85. package/dist/bridge/fs.js +0 -2231
  86. package/dist/bridge/index.d.ts +0 -10
  87. package/dist/bridge/index.js +0 -41
  88. package/dist/bridge/module.d.ts +0 -75
  89. package/dist/bridge/module.js +0 -299
  90. package/dist/bridge/network.d.ts +0 -250
  91. package/dist/bridge/network.js +0 -1433
  92. package/dist/bridge/os.d.ts +0 -13
  93. package/dist/bridge/os.js +0 -256
  94. package/dist/bridge/polyfills.d.ts +0 -2
  95. package/dist/bridge/polyfills.js +0 -11
  96. package/dist/bridge/process.d.ts +0 -89
  97. package/dist/bridge/process.js +0 -994
  98. package/dist/bridge.js +0 -11766
  99. package/dist/python-runtime.d.ts +0 -16
  100. package/dist/python-runtime.js +0 -45
  101. package/dist/runtime.d.ts +0 -31
  102. package/dist/runtime.js +0 -69
@@ -0,0 +1,631 @@
1
+ /**
2
+ * Process table.
3
+ *
4
+ * Universal process tracking across all runtimes. Owns PID allocation,
5
+ * parent-child relationships, waitpid, and signal routing. A WasmVM
6
+ * shell can waitpid on a Node child process.
7
+ */
8
+ import { KernelError, SIGCHLD, SIGALRM, SIGCONT, SIGSTOP, SIGTSTP, SIGKILL, WNOHANG, SA_RESETHAND, SIG_BLOCK, SIG_UNBLOCK, SIG_SETMASK } from "./types.js";
9
+ import { WaitQueue } from "./wait.js";
10
+ import { encodeExitStatus, encodeSignalStatus } from "./wstatus.js";
11
+ const ZOMBIE_TTL_MS = 60_000;
12
+ export class ProcessTable {
13
+ entries = new Map();
14
+ nextPid = 1;
15
+ waiters = new Map();
16
+ zombieTimers = new Map();
17
+ /** Pending alarm timers per PID: { timer, scheduledAt (ms epoch) }. */
18
+ alarmTimers = new Map();
19
+ /** Called when a process exits, before waiters are notified. */
20
+ onProcessExit = null;
21
+ /** Called when a zombie process is reaped (removed from the table). */
22
+ onProcessReap = null;
23
+ /** Atomically allocate the next PID. */
24
+ allocatePid() {
25
+ return this.nextPid++;
26
+ }
27
+ /** Register a process with a pre-allocated PID. */
28
+ register(pid, driver, command, args, ctx, driverProcess) {
29
+ // Inherit pgid/sid/umask from parent, or default to own pid / 0o022
30
+ const parent = ctx.ppid ? this.entries.get(ctx.ppid) : undefined;
31
+ const pgid = parent?.pgid ?? pid;
32
+ const sid = parent?.sid ?? pid;
33
+ const umask = parent?.umask ?? 0o022;
34
+ const entry = {
35
+ pid,
36
+ ppid: ctx.ppid,
37
+ pgid,
38
+ sid,
39
+ driver,
40
+ command,
41
+ args,
42
+ status: "running",
43
+ exitCode: null,
44
+ exitReason: null,
45
+ termSignal: 0,
46
+ exitTime: null,
47
+ env: { ...ctx.env },
48
+ cwd: ctx.cwd,
49
+ umask,
50
+ activeHandles: new Map(),
51
+ handleLimit: 0,
52
+ signalState: {
53
+ handlers: new Map(),
54
+ blockedSignals: new Set(),
55
+ pendingSignals: new Set(),
56
+ signalWaiters: new WaitQueue(),
57
+ deliverySeq: 0,
58
+ lastDeliveredSignal: null,
59
+ lastDeliveredFlags: 0,
60
+ },
61
+ driverProcess,
62
+ };
63
+ this.entries.set(pid, entry);
64
+ // Wire up exit callback to mark process as exited
65
+ driverProcess.onExit = (code) => {
66
+ this.markExited(pid, code);
67
+ };
68
+ return entry;
69
+ }
70
+ get(pid) {
71
+ return this.entries.get(pid);
72
+ }
73
+ /** Count pending zombie cleanup timers (test observability). */
74
+ get zombieTimerCount() {
75
+ return this.zombieTimers.size;
76
+ }
77
+ /** Count running (non-exited) processes. */
78
+ runningCount() {
79
+ let count = 0;
80
+ for (const entry of this.entries.values()) {
81
+ if (entry.status === "running")
82
+ count++;
83
+ }
84
+ return count;
85
+ }
86
+ /** Mark a process as exited with the given code. Notifies waiters. */
87
+ markExited(pid, exitCode) {
88
+ const entry = this.entries.get(pid);
89
+ if (!entry)
90
+ return;
91
+ if (entry.status === "exited")
92
+ return;
93
+ entry.status = "exited";
94
+ entry.exitCode = exitCode;
95
+ entry.exitReason = entry.termSignal > 0 ? "signal" : "normal";
96
+ entry.exitTime = Date.now();
97
+ // Encode POSIX wstatus
98
+ const wstatus = entry.termSignal > 0
99
+ ? encodeSignalStatus(entry.termSignal)
100
+ : encodeExitStatus(exitCode);
101
+ // Cancel pending alarm
102
+ this.cancelAlarm(pid);
103
+ // Clear all active handles
104
+ entry.activeHandles.clear();
105
+ // Clean up process resources (FD table, pipe ends)
106
+ this.onProcessExit?.(pid);
107
+ // Deliver SIGCHLD to parent via signal handler system
108
+ if (entry.ppid > 0) {
109
+ const parent = this.entries.get(entry.ppid);
110
+ if (parent && parent.status === "running") {
111
+ this.deliverSignal(parent, SIGCHLD);
112
+ }
113
+ }
114
+ // Notify waiters
115
+ const waiters = this.waiters.get(pid);
116
+ if (waiters) {
117
+ for (const resolve of waiters) {
118
+ resolve({ pid, status: wstatus, termSignal: entry.termSignal });
119
+ }
120
+ this.waiters.delete(pid);
121
+ }
122
+ // Schedule zombie cleanup (tracked for cancellation on dispose)
123
+ const timer = setTimeout(() => {
124
+ this.zombieTimers.delete(pid);
125
+ this.reap(pid);
126
+ }, ZOMBIE_TTL_MS);
127
+ this.zombieTimers.set(pid, timer);
128
+ }
129
+ /**
130
+ * Wait for a process to exit.
131
+ * If already exited, resolves immediately. Otherwise blocks until exit.
132
+ * With WNOHANG option, returns null immediately if process is still running.
133
+ */
134
+ waitpid(pid, options) {
135
+ const entry = this.entries.get(pid);
136
+ if (!entry) {
137
+ return Promise.reject(new Error(`ESRCH: no such process ${pid}`));
138
+ }
139
+ if (entry.status === "exited") {
140
+ const wstatus = entry.termSignal > 0
141
+ ? encodeSignalStatus(entry.termSignal)
142
+ : encodeExitStatus(entry.exitCode);
143
+ return Promise.resolve({ pid, status: wstatus, termSignal: entry.termSignal });
144
+ }
145
+ // WNOHANG: return null immediately if process is still running
146
+ if (options && (options & WNOHANG)) {
147
+ return Promise.resolve(null);
148
+ }
149
+ return new Promise((resolve) => {
150
+ let waiters = this.waiters.get(pid);
151
+ if (!waiters) {
152
+ waiters = [];
153
+ this.waiters.set(pid, waiters);
154
+ }
155
+ waiters.push(resolve);
156
+ });
157
+ }
158
+ /**
159
+ * Send a signal to a process or process group.
160
+ * If pid > 0, signal a single process.
161
+ * If pid < 0, signal all processes in process group abs(pid).
162
+ */
163
+ kill(pid, signal) {
164
+ // Validate signal range (POSIX: 0 = existence check, 1-64 = real signals)
165
+ if (signal < 0 || signal > 64) {
166
+ throw new KernelError("EINVAL", `invalid signal ${signal}`);
167
+ }
168
+ if (pid < 0) {
169
+ // Process group kill
170
+ const pgid = -pid;
171
+ let found = false;
172
+ for (const entry of this.entries.values()) {
173
+ if (entry.pgid === pgid && entry.status !== "exited") {
174
+ found = true;
175
+ if (signal !== 0) {
176
+ this.deliverSignal(entry, signal);
177
+ }
178
+ }
179
+ }
180
+ if (!found)
181
+ throw new KernelError("ESRCH", `no such process group ${pgid}`);
182
+ return;
183
+ }
184
+ const entry = this.entries.get(pid);
185
+ if (!entry)
186
+ throw new KernelError("ESRCH", `no such process ${pid}`);
187
+ if (entry.status === "exited")
188
+ return;
189
+ // Signal 0: existence check only — don't deliver
190
+ if (signal === 0)
191
+ return;
192
+ this.deliverSignal(entry, signal);
193
+ }
194
+ /**
195
+ * Deliver a signal to a process, respecting handlers, blocking, and coalescing.
196
+ *
197
+ * SIGKILL and SIGSTOP cannot be caught, blocked, or ignored (POSIX).
198
+ * Blocked signals are queued in pendingSignals; standard signals (1-31) coalesce.
199
+ * If a handler is registered, it is invoked with sa_mask temporarily blocked.
200
+ */
201
+ deliverSignal(entry, signal) {
202
+ const { signalState } = entry;
203
+ // SIGKILL and SIGSTOP always use default action — cannot be caught/blocked/ignored
204
+ if (signal === SIGKILL || signal === SIGSTOP) {
205
+ this.applyDefaultAction(entry, signal);
206
+ return;
207
+ }
208
+ // SIGCONT always resumes a stopped process, even if blocked or caught (POSIX)
209
+ if (signal === SIGCONT) {
210
+ this.cont(entry.pid);
211
+ // If blocked, queue for handler delivery later; otherwise dispatch
212
+ if (signalState.blockedSignals.has(signal)) {
213
+ signalState.pendingSignals.add(signal);
214
+ return;
215
+ }
216
+ this.dispatchSignal(entry, signal);
217
+ return;
218
+ }
219
+ // If signal is blocked, queue it (standard signals 1-31 coalesce via Set)
220
+ if (signalState.blockedSignals.has(signal)) {
221
+ signalState.pendingSignals.add(signal);
222
+ return;
223
+ }
224
+ this.dispatchSignal(entry, signal);
225
+ }
226
+ /**
227
+ * Dispatch a signal to a process — check handler, then apply.
228
+ * Called for unblocked signals and when delivering pending signals.
229
+ */
230
+ dispatchSignal(entry, signal) {
231
+ const { signalState } = entry;
232
+ const registration = signalState.handlers.get(signal);
233
+ if (!registration) {
234
+ // No handler registered — apply default action
235
+ if (signal !== SIGCHLD) {
236
+ this.recordSignalDelivery(signalState, signal, 0);
237
+ }
238
+ this.applyDefaultAction(entry, signal);
239
+ return;
240
+ }
241
+ const { handler, mask, flags } = registration;
242
+ if (handler === "ignore")
243
+ return;
244
+ if (handler === "default") {
245
+ if (signal !== SIGCHLD) {
246
+ this.recordSignalDelivery(signalState, signal, 0);
247
+ }
248
+ this.applyDefaultAction(entry, signal);
249
+ return;
250
+ }
251
+ this.recordSignalDelivery(signalState, signal, flags);
252
+ // User-defined handler: temporarily block sa_mask + the signal itself during execution
253
+ const savedBlocked = new Set(signalState.blockedSignals);
254
+ for (const s of mask)
255
+ signalState.blockedSignals.add(s);
256
+ signalState.blockedSignals.add(signal);
257
+ try {
258
+ handler(signal);
259
+ }
260
+ finally {
261
+ // Restore previous blocked set
262
+ signalState.blockedSignals = savedBlocked;
263
+ }
264
+ // Reset one-shot handlers before any pending re-delivery.
265
+ if ((flags & SA_RESETHAND) !== 0) {
266
+ signalState.handlers.set(signal, {
267
+ handler: "default",
268
+ mask: new Set(),
269
+ flags: 0,
270
+ });
271
+ }
272
+ // Deliver any signals that were pending and are now unblocked
273
+ this.deliverPendingSignals(entry);
274
+ }
275
+ /** Wake signal-aware waiters after a signal has been dispatched. */
276
+ recordSignalDelivery(signalState, signal, flags) {
277
+ signalState.lastDeliveredSignal = signal;
278
+ signalState.lastDeliveredFlags = flags;
279
+ signalState.deliverySeq++;
280
+ signalState.signalWaiters.wakeAll();
281
+ }
282
+ /** Apply the kernel default action for a signal. */
283
+ applyDefaultAction(entry, signal) {
284
+ if (signal === SIGTSTP || signal === SIGSTOP) {
285
+ this.stop(entry.pid);
286
+ entry.driverProcess.kill(signal);
287
+ }
288
+ else if (signal === SIGCONT) {
289
+ this.cont(entry.pid);
290
+ entry.driverProcess.kill(signal);
291
+ }
292
+ else if (signal === SIGCHLD) {
293
+ // Default SIGCHLD action: ignore (don't terminate)
294
+ return;
295
+ }
296
+ else {
297
+ entry.termSignal = signal;
298
+ entry.driverProcess.kill(signal);
299
+ }
300
+ }
301
+ /** Deliver pending signals that are no longer blocked (lowest signal number first). */
302
+ deliverPendingSignals(entry) {
303
+ const { signalState } = entry;
304
+ if (signalState.pendingSignals.size === 0)
305
+ return;
306
+ // Deliver in ascending signal number order
307
+ const pending = [...signalState.pendingSignals].sort((a, b) => a - b);
308
+ for (const sig of pending) {
309
+ // Check both: not blocked AND still pending (recursive delivery may have handled it)
310
+ if (!signalState.blockedSignals.has(sig) && signalState.pendingSignals.has(sig)) {
311
+ signalState.pendingSignals.delete(sig);
312
+ this.dispatchSignal(entry, sig);
313
+ if (entry.status === "exited")
314
+ break;
315
+ }
316
+ }
317
+ }
318
+ /**
319
+ * Schedule SIGALRM delivery after `seconds`. Returns previous alarm remaining (0 if none).
320
+ * alarm(pid, 0) cancels any pending alarm. A new alarm replaces the previous one.
321
+ */
322
+ alarm(pid, seconds) {
323
+ const entry = this.entries.get(pid);
324
+ if (!entry)
325
+ throw new KernelError("ESRCH", `no such process ${pid}`);
326
+ // Calculate remaining time from any existing alarm
327
+ let remaining = 0;
328
+ const existing = this.alarmTimers.get(pid);
329
+ if (existing) {
330
+ const elapsed = (Date.now() - existing.scheduledAt) / 1000;
331
+ remaining = Math.max(0, Math.ceil(existing.seconds - elapsed));
332
+ clearTimeout(existing.timer);
333
+ this.alarmTimers.delete(pid);
334
+ }
335
+ if (seconds === 0)
336
+ return remaining;
337
+ // Schedule new alarm
338
+ const scheduledAt = Date.now();
339
+ const timer = setTimeout(() => {
340
+ this.alarmTimers.delete(pid);
341
+ const e = this.entries.get(pid);
342
+ if (!e || e.status !== "running")
343
+ return;
344
+ // Deliver through signal handler system
345
+ this.deliverSignal(e, SIGALRM);
346
+ }, seconds * 1000);
347
+ this.alarmTimers.set(pid, { timer, scheduledAt, seconds });
348
+ return remaining;
349
+ }
350
+ // -----------------------------------------------------------------------
351
+ // Signal handlers (sigaction / sigprocmask)
352
+ // -----------------------------------------------------------------------
353
+ /**
354
+ * Register a signal handler (POSIX sigaction).
355
+ * Returns the previous handler for the signal, or undefined if none was set.
356
+ * SIGKILL and SIGSTOP cannot be caught or ignored.
357
+ */
358
+ sigaction(pid, signal, handler) {
359
+ const entry = this.entries.get(pid);
360
+ if (!entry)
361
+ throw new KernelError("ESRCH", `no such process ${pid}`);
362
+ if (signal < 1 || signal > 64)
363
+ throw new KernelError("EINVAL", `invalid signal ${signal}`);
364
+ if (signal === SIGKILL || signal === SIGSTOP) {
365
+ throw new KernelError("EINVAL", `cannot catch or ignore signal ${signal}`);
366
+ }
367
+ const prev = entry.signalState.handlers.get(signal);
368
+ entry.signalState.handlers.set(signal, handler);
369
+ return prev;
370
+ }
371
+ /**
372
+ * Modify the blocked signal mask (POSIX sigprocmask).
373
+ * Returns the previous blocked set.
374
+ * SIGKILL and SIGSTOP cannot be blocked.
375
+ */
376
+ sigprocmask(pid, how, set) {
377
+ const entry = this.entries.get(pid);
378
+ if (!entry)
379
+ throw new KernelError("ESRCH", `no such process ${pid}`);
380
+ const { signalState } = entry;
381
+ const prevBlocked = new Set(signalState.blockedSignals);
382
+ // Filter out uncatchable signals
383
+ const filtered = new Set(set);
384
+ filtered.delete(SIGKILL);
385
+ filtered.delete(SIGSTOP);
386
+ if (how === SIG_BLOCK) {
387
+ for (const s of filtered)
388
+ signalState.blockedSignals.add(s);
389
+ }
390
+ else if (how === SIG_UNBLOCK) {
391
+ for (const s of filtered)
392
+ signalState.blockedSignals.delete(s);
393
+ }
394
+ else if (how === SIG_SETMASK) {
395
+ signalState.blockedSignals = filtered;
396
+ }
397
+ else {
398
+ throw new KernelError("EINVAL", `invalid sigprocmask how: ${how}`);
399
+ }
400
+ // Deliver any pending signals that are now unblocked
401
+ this.deliverPendingSignals(entry);
402
+ return prevBlocked;
403
+ }
404
+ /** Get the signal state for a process (read-only view). */
405
+ getSignalState(pid) {
406
+ const entry = this.entries.get(pid);
407
+ if (!entry)
408
+ throw new KernelError("ESRCH", `no such process ${pid}`);
409
+ return entry.signalState;
410
+ }
411
+ /** Suspend a process (SIGTSTP/SIGSTOP). Sets status to 'stopped'. */
412
+ stop(pid) {
413
+ const entry = this.entries.get(pid);
414
+ if (!entry)
415
+ throw new KernelError("ESRCH", `no such process ${pid}`);
416
+ if (entry.status !== "running")
417
+ return;
418
+ entry.status = "stopped";
419
+ }
420
+ /** Resume a stopped process (SIGCONT). Sets status back to 'running'. */
421
+ cont(pid) {
422
+ const entry = this.entries.get(pid);
423
+ if (!entry)
424
+ throw new KernelError("ESRCH", `no such process ${pid}`);
425
+ if (entry.status !== "stopped")
426
+ return;
427
+ entry.status = "running";
428
+ }
429
+ /** Cancel a pending alarm for a process. */
430
+ cancelAlarm(pid) {
431
+ const existing = this.alarmTimers.get(pid);
432
+ if (existing) {
433
+ clearTimeout(existing.timer);
434
+ this.alarmTimers.delete(pid);
435
+ }
436
+ }
437
+ /** Set process group ID. Process can join existing group or create new one. */
438
+ setpgid(pid, pgid) {
439
+ const entry = this.entries.get(pid);
440
+ if (!entry)
441
+ throw new KernelError("ESRCH", `no such process ${pid}`);
442
+ // pgid 0 means "use own PID as pgid"
443
+ const targetPgid = pgid === 0 ? pid : pgid;
444
+ // Can only join an existing group or create own group
445
+ if (targetPgid !== pid) {
446
+ let groupExists = false;
447
+ for (const e of this.entries.values()) {
448
+ if (e.pgid === targetPgid && e.status !== "exited") {
449
+ // Reject cross-session group joining (POSIX)
450
+ if (e.sid !== entry.sid) {
451
+ throw new KernelError("EPERM", `cannot join process group in different session`);
452
+ }
453
+ groupExists = true;
454
+ break;
455
+ }
456
+ }
457
+ if (!groupExists)
458
+ throw new KernelError("EPERM", `no such process group ${targetPgid}`);
459
+ }
460
+ entry.pgid = targetPgid;
461
+ }
462
+ /** Get process group ID. */
463
+ getpgid(pid) {
464
+ const entry = this.entries.get(pid);
465
+ if (!entry)
466
+ throw new KernelError("ESRCH", `no such process ${pid}`);
467
+ return entry.pgid;
468
+ }
469
+ /** Create a new session. Process becomes session leader and process group leader. */
470
+ setsid(pid) {
471
+ const entry = this.entries.get(pid);
472
+ if (!entry)
473
+ throw new KernelError("ESRCH", `no such process ${pid}`);
474
+ // Process must not already be a process group leader
475
+ if (entry.pgid === pid) {
476
+ throw new KernelError("EPERM", `process ${pid} is already a process group leader`);
477
+ }
478
+ entry.sid = pid;
479
+ entry.pgid = pid;
480
+ return pid;
481
+ }
482
+ /** Get session ID. */
483
+ getsid(pid) {
484
+ const entry = this.entries.get(pid);
485
+ if (!entry)
486
+ throw new KernelError("ESRCH", `no such process ${pid}`);
487
+ return entry.sid;
488
+ }
489
+ /** Get the parent PID for a process. */
490
+ getppid(pid) {
491
+ const entry = this.entries.get(pid);
492
+ if (!entry)
493
+ throw new KernelError("ESRCH", `no such process ${pid}`);
494
+ return entry.ppid;
495
+ }
496
+ /**
497
+ * Send a signal to a process group, skipping session leaders.
498
+ * Returns count of processes actually signaled.
499
+ * Used for PTY-originated SIGINT where the session leader (shell)
500
+ * cannot handle signals gracefully (WasmVM worker.terminate()).
501
+ */
502
+ killGroupExcludeLeaders(pgid, signal) {
503
+ if (signal < 0 || signal > 64) {
504
+ throw new KernelError("EINVAL", `invalid signal ${signal}`);
505
+ }
506
+ let count = 0;
507
+ for (const entry of this.entries.values()) {
508
+ if (entry.pgid === pgid && entry.status !== "exited") {
509
+ if (entry.pid === entry.sid)
510
+ continue; // Skip session leaders
511
+ if (signal !== 0) {
512
+ this.deliverSignal(entry, signal);
513
+ }
514
+ count++;
515
+ }
516
+ }
517
+ return count;
518
+ }
519
+ /** Check if any running process belongs to the given process group. */
520
+ hasProcessGroup(pgid) {
521
+ for (const entry of this.entries.values()) {
522
+ if (entry.pgid === pgid && entry.status !== "exited")
523
+ return true;
524
+ }
525
+ return false;
526
+ }
527
+ /** Get a read-only view of process info for all processes. */
528
+ listProcesses() {
529
+ const result = new Map();
530
+ for (const [pid, entry] of this.entries) {
531
+ result.set(pid, {
532
+ pid: entry.pid,
533
+ ppid: entry.ppid,
534
+ pgid: entry.pgid,
535
+ sid: entry.sid,
536
+ driver: entry.driver,
537
+ command: entry.command,
538
+ status: entry.status,
539
+ exitCode: entry.exitCode,
540
+ });
541
+ }
542
+ return result;
543
+ }
544
+ /** Remove a zombie process. */
545
+ reap(pid) {
546
+ const entry = this.entries.get(pid);
547
+ if (entry?.status === "exited") {
548
+ this.onProcessReap?.(pid);
549
+ this.entries.delete(pid);
550
+ }
551
+ }
552
+ // -----------------------------------------------------------------------
553
+ // Handle tracking
554
+ // -----------------------------------------------------------------------
555
+ /** Register an active handle for a process. Throws EAGAIN if budget exceeded. */
556
+ registerHandle(pid, id, description) {
557
+ const entry = this.entries.get(pid);
558
+ if (!entry)
559
+ throw new KernelError("ESRCH", `no such process ${pid}`);
560
+ if (entry.handleLimit > 0 && entry.activeHandles.size >= entry.handleLimit) {
561
+ throw new KernelError("EAGAIN", `handle limit (${entry.handleLimit}) exceeded for process ${pid}`);
562
+ }
563
+ entry.activeHandles.set(id, description);
564
+ }
565
+ /** Unregister an active handle. Throws EBADF if handle not found. */
566
+ unregisterHandle(pid, id) {
567
+ const entry = this.entries.get(pid);
568
+ if (!entry)
569
+ throw new KernelError("ESRCH", `no such process ${pid}`);
570
+ if (!entry.activeHandles.delete(id)) {
571
+ throw new KernelError("EBADF", `no such handle ${id} for process ${pid}`);
572
+ }
573
+ }
574
+ /** Set the maximum number of active handles for a process. 0 = unlimited. */
575
+ setHandleLimit(pid, limit) {
576
+ const entry = this.entries.get(pid);
577
+ if (!entry)
578
+ throw new KernelError("ESRCH", `no such process ${pid}`);
579
+ entry.handleLimit = limit;
580
+ }
581
+ /** Get the active handles for a process (read-only copy). */
582
+ getHandles(pid) {
583
+ const entry = this.entries.get(pid);
584
+ if (!entry)
585
+ throw new KernelError("ESRCH", `no such process ${pid}`);
586
+ return new Map(entry.activeHandles);
587
+ }
588
+ /** Terminate all running processes and clear pending timers. */
589
+ async terminateAll() {
590
+ // Clear all zombie cleanup timers to prevent post-dispose firings
591
+ for (const timer of this.zombieTimers.values()) {
592
+ clearTimeout(timer);
593
+ }
594
+ this.zombieTimers.clear();
595
+ // Clear all pending alarm timers
596
+ for (const { timer } of this.alarmTimers.values()) {
597
+ clearTimeout(timer);
598
+ }
599
+ this.alarmTimers.clear();
600
+ const running = [...this.entries.values()].filter((e) => e.status !== "exited");
601
+ for (const entry of running) {
602
+ try {
603
+ entry.driverProcess.kill(15); // SIGTERM
604
+ }
605
+ catch {
606
+ // Best effort
607
+ }
608
+ }
609
+ // Wait briefly for graceful exits
610
+ await Promise.allSettled(running.map((e) => Promise.race([
611
+ e.driverProcess.wait(),
612
+ new Promise((r) => setTimeout(r, 1000)),
613
+ ])));
614
+ // Escalate to SIGKILL for processes that survived SIGTERM
615
+ const survivors = running.filter((e) => e.status !== "exited");
616
+ for (const entry of survivors) {
617
+ try {
618
+ entry.driverProcess.kill(9); // SIGKILL
619
+ }
620
+ catch {
621
+ // Best effort
622
+ }
623
+ }
624
+ if (survivors.length > 0) {
625
+ await Promise.allSettled(survivors.map((e) => Promise.race([
626
+ e.driverProcess.wait(),
627
+ new Promise((r) => setTimeout(r, 500)),
628
+ ])));
629
+ }
630
+ }
631
+ }