@ai-ecoverse/slicc-kernel 1.54.3 → 1.54.5

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.
package/README.md CHANGED
@@ -186,8 +186,8 @@ A descriptor a program inherits at one of those numbers (from bash's `3<file`, o
186
186
  On top of preview1 a WASI program gets:
187
187
 
188
188
  - **Threads**: `wasi.thread-spawn` (wasm32-wasip1-threads) and WASIX `thread_spawn_v2` start a worker per thread on the process's shared memory, sized from the module's own import (a 2 GiB maximum when the engine cannot reserve more). The threads share one descriptor table; `exit` or a trap in any thread ends the process. At most 64 threads per process; `SLICC_WASM_THREADS` sets another cap, up to 256.
189
- - **WASIX** (a module importing `wasix_32v1`): fork and setjmp/longjmp through Asyncify (the module is built with `wasm-opt --asyncify`), exec, `posix_spawn`, `waitpid` (with `WNOHANG`, and `WUNTRACED` for a child that stops; WASIX has no `WCONTINUED`), pipes, `dup2`, signal handlers and interval timers, terminal modes, and dynamic linking: a position-independent main module loads side modules with `dlopen` from `LD_LIBRARY_PATH`, its runtime path, `/lib`, `/usr/lib` and `/usr/local/lib`. Each thread has its own copy of the function table, so the kernel hands out its slots (a side module's range, a `dlsym`'d function, a closure): threads loading or looking up at the same time never get the same slot. `dlopen(NULL)`, and `dlopen` of the program's own file (also by a path that resolves to it through symlinks), give a handle for the program that is already running (no second copy is loaded); `dlsym` on it searches the program, then the loaded libraries, and `dlclose` of it does nothing. A function already in the table (the program's own, or a library's) keeps its slot, so `dlsym(handle, "strlen") == strlen`.
190
- - **Foreign calls** (libffi's WASIX backend, so Python's `ctypes`): `call_dynamic` calls a function through the program's indirect function table (a static module's exported `__indirect_function_table`, or the dynamic linker's), its arguments and results packed in memory one after another (`i32`/`f32` 4 bytes, `i64`/`f64` 8). With `strict` set, the buffers must fit the function exactly (else `EINVAL`); without it, missing arguments are zero and results that do not fit are left out. A trap or exception of the callee goes through to the caller. JS cannot ask a function for its type, so the kernel reads the types from the module files (the program's and each side module's, once, when a program first calls through `call_dynamic`): a table slot that is empty, past the end, holds a function of no module the kernel knows of, or takes or returns a `v128`, gives `EINVAL`. For closures (callbacks such as `CFUNCTYPE`), a dynamically linked program gets table slots from `closure_allocate`, and `closure_free` gives them back for reuse and empties the slot in every thread; a static program gets `ENOTSUP`. `closure_prepare` puts a trampoline with the requested signature (any of `i32`, `i64`, `f32`, `f64`, `v128`) into such a slot, in every thread: each call stores its arguments below the stack pointer and calls the backing function as `backing(values, results, user_data)`, then returns what it wrote to `results`. The trampolines are small wasm modules made and compiled at run time, once per signature in each thread (an instance per closure), so a page whose Content Security Policy forbids compiling WebAssembly (no `'wasm-unsafe-eval'`) cannot run them; it cannot run the program either. A `fork` or a `longjmp` (both through Asyncify) inside a function reached through `call_dynamic` cannot unwind through it.
189
+ - **WASIX** (a module importing `wasix_32v1`): fork and setjmp/longjmp through Asyncify (the module is built with `wasm-opt --asyncify`), exec, `posix_spawn`, `waitpid` (with `WNOHANG`, and `WUNTRACED` for a child that stops; WASIX has no `WCONTINUED`), pipes, `dup2`, signal handlers and interval timers, terminal modes, and dynamic linking: a position-independent main module loads side modules with `dlopen` from `LD_LIBRARY_PATH`, its runtime path, `/lib`, `/usr/lib` and `/usr/local/lib`. Each thread has its own copy of the function table, so the kernel hands out its slots (a side module's range, a `dlsym`'d function, a closure): threads loading or looking up at the same time never get the same slot. As in glibc, one `dlopen` at a time runs in a process, constructors included: a `dlopen` from a library's constructor goes ahead in its own thread, and another thread's `dlopen` waits, then finds the library loaded and gets the same handle. A thread waiting there still ends or stops with its process, but a signal handler it has runs once that `dlopen` returns. The lock is let go when a `dlopen` fails (an exception, a trap or `pthread_exit` out of a constructor included) and when its thread ends; a forked child starts with it free. In such a program, `dlopen(NULL)`, and `dlopen` of the program's own file (also by a path that resolves to it through symlinks), give a handle for the program that is already running (no second copy is loaded); a program that isn't position-independent gets an error from `dlopen(NULL)`, as from any `dlopen`; `dlsym` on it searches the program, then the loaded libraries, and `dlclose` of it does nothing. A function already in the table (the program's own, or a library's) keeps its slot, so `dlsym(handle, "strlen") == strlen`.
190
+ - **Foreign calls** (libffi's WASIX backend, so Python's `ctypes`): `call_dynamic` calls a function through the program's indirect function table (a static module's exported `__indirect_function_table`, or the dynamic linker's), its arguments and results packed in memory one after another (`i32`/`f32` 4 bytes, `i64`/`f64` 8). With `strict` set, the buffers must fit the function exactly (else `EINVAL`, before the function is called); without it, missing arguments are zero (one cut short keeps the bytes given, with zeros above them) and results that do not fit are left out. A trap or exception of the callee goes through to the caller. JS cannot ask a function for its type, so the kernel reads the types from the module files (the program's and each side module's, once, when a program first calls through `call_dynamic`): a table slot that is empty, past the end, holds a function of no module the kernel knows of, or takes or returns a `v128`, gives `EINVAL`. For closures (callbacks such as `CFUNCTYPE`), a dynamically linked program gets table slots from `closure_allocate`, and `closure_free` gives them back for reuse and empties the slot in every thread; a static program gets `ENOTSUP`. `closure_prepare` puts a trampoline with the requested signature (any of `i32`, `i64`, `f32`, `f64`, `v128`) into such a slot, in every thread: each call stores its arguments below the stack pointer and calls the backing function as `backing(values, results, user_data)`, then returns what it wrote to `results`. The trampolines are small wasm modules made and compiled at run time, once per signature in each thread (an instance per closure), so a page whose Content Security Policy forbids compiling WebAssembly (no `'wasm-unsafe-eval'`) cannot run them; it cannot run the program either. A `fork` or a `longjmp` (both through Asyncify) inside a function reached through `call_dynamic` cannot unwind through it.
191
191
  - **Job control:** `^Z`, `SIGTSTP`, `SIGTTIN` and `SIGTTOU` stop a WASIX program at once, unless it has a handler for them. A stopped program that is busy computing is stopped for job control (`jobs` shows it, `fg`, `bg` and `SIGCONT` work), but its worker keeps a core busy until its next system call, since a worker's running code cannot be frozen from outside. Agents should `kill` such a job rather than leave it stopped. For the same reason, a signal with a handler or a terminating default (`^C`, `SIGTERM`) reaches a program that is busy computing only at its next system call, so a CPU-bound loop may not end on `^C`; `kill -9` always ends it. A thread blocked in the kernel or waiting on a lock (`pthread_cond_wait`, a futex) is woken by a signal for it (a futex wait checks for signals every 50 ms, so each waiting thread wakes briefly that often), and `pthread_kill` runs the handler on the thread it names. A signal that ends a sleep or a `poll`/`select` with a timeout makes the call fail with `EINTR` in a program whose libc says it is wasix-sysroot 2025.9.30-22 or later (its `slicc.libc` custom section). Older libcs turn that answer into `ENOTSUP` from `nanosleep` (up to -18), so for a program without the section the wait ends as if it had timed out, unless it imports `fd_fdflags_set` (then `EINTR`, as before).
192
192
  - **Pipes** have no file type for WASI and WASIX programs' `fstat` (`st_mode` type 0), because WASI has no FIFO file type. A socket type, as kernels 1.47.3 to 1.48.1 gave them, made Ruby treat a `popen` pipe as a socket (`close_write` then never ends the child's input). The kernel's `slicc_fs.fd_mode` answers `S_IFIFO | 0600` (Linux numbering) for a pipe, and wasix-sysroot 2025.9.30-21 and later take the type from it, so programs built on it see a FIFO (`S_ISFIFO`); older libcs keep only the permission bits. Until then Python's asyncio refuses a pipe for a subprocess (`Pipe transport is only for pipes, sockets and character devices`). They are read and written as pipes; socket calls on them fail with `ENOTSOCK`, and `isatty()` is false. Emscripten programs, bash included, see a FIFO. The same holds for any kernel descriptor that is neither seekable nor a terminal or device, such as captured output.
193
193
  - **Sockets**: the WASIX socket calls (`sock_open`, `sock_pair` for an `AF_UNIX` stream pair, `bind`, `listen`, `connect`, `accept`, options, local and peer names) are the kernel's loopback sockets, and preview1's `sock_recv` / `sock_send` / `sock_shutdown` work on them and on inherited ones. Writing to a socket whose peer is gone fails with `EPIPE`, through `fd_write` as through `sock_send`, as if `MSG_NOSIGNAL` were set, since a WASI program cannot ignore `SIGPIPE`; a pipe or terminal whose reader is gone still ends the program with status 141. HTTP and HTTPS leave through the realm proxy that `https_proxy` names, as for any other program; `resolve` answers `/etc/hosts` names and literal addresses, and the [uplink](#uplink)'s names.
@@ -8152,12 +8152,14 @@ var AsyncOps = class {
8152
8152
  const at = this.ready.indexOf(id2);
8153
8153
  if (at >= 0) this.ready.splice(at, 1);
8154
8154
  }
8155
- async wait(timeoutMs, signal) {
8155
+ async wait(timeoutMs, blocking) {
8156
+ let signal;
8156
8157
  for (; ; ) {
8157
8158
  if (this.closed) return -1;
8158
8159
  const id2 = this.ready.shift();
8159
8160
  if (id2 !== void 0) return id2;
8160
8161
  if (timeoutMs !== void 0 && timeoutMs <= 0) return 0;
8162
+ signal ??= blocking();
8161
8163
  const woke = await this.sleep(timeoutMs, signal);
8162
8164
  if (!woke) return 0;
8163
8165
  }
@@ -8218,7 +8220,7 @@ async function hostSyscall(req, ctx) {
8218
8220
  if (!ctx.valid(req.req)) throw new KernelError("EINVAL");
8219
8221
  return json3(ctx.ops.submit(req.req));
8220
8222
  case "async-wait":
8221
- return json3(await ctx.ops.wait(req.timeoutMs, ctx.blocking()));
8223
+ return json3(await ctx.ops.wait(req.timeoutMs, ctx.blocking));
8222
8224
  case "async-take":
8223
8225
  return ctx.ops.take(req.id);
8224
8226
  case "async-resolve":
@@ -9820,6 +9822,9 @@ var SYSCALL_OPS = /* @__PURE__ */ new Set([
9820
9822
  "dl-closure",
9821
9823
  "dl-closure-free",
9822
9824
  "dl-reserve",
9825
+ "dl-load-reserve",
9826
+ "dl-lock",
9827
+ "dl-unlock",
9823
9828
  "fd-renumber",
9824
9829
  "fd-promote",
9825
9830
  "fd-open-tty",
@@ -9879,7 +9884,7 @@ var WasmProcess = class {
9879
9884
  stopped = 0;
9880
9885
  stops = 0;
9881
9886
  owed = false;
9882
- waits = 0;
9887
+ waiting = /* @__PURE__ */ new Set();
9883
9888
  resumed = Promise.resolve();
9884
9889
  wake;
9885
9890
  stateListeners = [];
@@ -9935,7 +9940,8 @@ var WasmProcess = class {
9935
9940
  return "terminate";
9936
9941
  }
9937
9942
  this.options.onPending?.(sig);
9938
- if (this.options.jobs?.keyboard && KEYBOARD.has(sig)) this.owed = true;
9943
+ if (this.options.jobs?.keyboard && KEYBOARD.has(sig) && this.waiting.size === 0)
9944
+ this.owed = true;
9939
9945
  const blocked = this.interrupt;
9940
9946
  this.interrupt = new AbortController();
9941
9947
  blocked.abort();
@@ -9980,10 +9986,15 @@ var WasmProcess = class {
9980
9986
  await this.resumed;
9981
9987
  if (this.dying) return DYING;
9982
9988
  const stops = this.stops;
9983
- const waits = this.waits;
9984
- const result = await this.dispatch(req, cancelled);
9989
+ const scope = new AbortController();
9990
+ const forward = () => scope.abort();
9991
+ if (cancelled?.aborted) scope.abort();
9992
+ cancelled?.addEventListener("abort", forward, { once: true });
9993
+ const result = await this.dispatch(req, scope.signal).finally(() => {
9994
+ cancelled?.removeEventListener("abort", forward);
9995
+ this.waiting.delete(scope.signal);
9996
+ });
9985
9997
  if (this.dying) return DYING;
9986
- if (!result.ok && result.errno === "EINTR" && this.waits !== waits) this.owed = false;
9987
9998
  const restart = !result.ok && result.errno === "EINTR" && this.stops !== stops;
9988
9999
  if (restart && !this.options.hasPending?.()) continue;
9989
10000
  await this.resumed;
@@ -10036,12 +10047,31 @@ var WasmProcess = class {
10036
10047
  refuseIfPending(restart) {
10037
10048
  if (restart && this.options.hasPending?.()) throw new KernelError("EINTR");
10038
10049
  }
10050
+ async procWait(req, cancelled) {
10051
+ const flags = {
10052
+ untraced: req.untraced,
10053
+ continued: req.continued,
10054
+ inGroup: this.waitGroup(req.pid)
10055
+ };
10056
+ if (this.owed && !req.nohang) {
10057
+ const now = await this.children.wait(req.pid, true, this.interrupt.signal, flags);
10058
+ if (now[0] !== 0) return now;
10059
+ }
10060
+ const signal = req.nohang ? this.interrupt.signal : this.blockingSignal(cancelled);
10061
+ const waited = await this.children.wait(req.pid, req.nohang, signal, flags);
10062
+ return waited;
10063
+ }
10039
10064
  blockingSignal(cancelled, owing = true) {
10040
- if (owing) this.waits++;
10041
- if (this.options.hasPending?.() || cancelled?.aborted || owing && this.owed) {
10065
+ if (owing && this.owed) {
10066
+ this.owed = false;
10042
10067
  throw new KernelError("EINTR");
10043
10068
  }
10044
- return cancelled ? AbortSignal.any([this.interrupt.signal, cancelled]) : this.interrupt.signal;
10069
+ if (this.options.hasPending?.() || cancelled.aborted) throw new KernelError("EINTR");
10070
+ if (owing) this.waiting.add(cancelled);
10071
+ return this.cancelSignal(cancelled);
10072
+ }
10073
+ cancelSignal(cancelled) {
10074
+ return AbortSignal.any([this.interrupt.signal, cancelled]);
10045
10075
  }
10046
10076
  async read(req, cancelled) {
10047
10077
  const file = this.fds.get(req.fd).file;
@@ -10065,7 +10095,7 @@ var WasmProcess = class {
10065
10095
  if (nonblock) return file.write(body, AbortSignal.abort());
10066
10096
  return file.write(
10067
10097
  body,
10068
- this.options.hasPending?.() ? AbortSignal.abort() : this.interrupt.signal
10098
+ this.options.hasPending?.() ? AbortSignal.abort() : this.cancelSignal(cancelled)
10069
10099
  );
10070
10100
  }
10071
10101
  async openVfs(req) {
@@ -10209,6 +10239,11 @@ var WasmProcess = class {
10209
10239
  return { ok: true, kind: "json", json: this.options.jobs?.terminalNames() ?? [] };
10210
10240
  case "fd-select": {
10211
10241
  const { read, write, timeoutMs } = req;
10242
+ if (this.owed && timeoutMs !== 0) {
10243
+ const ready2 = await selectFds(this.fds, read, write, 0, this.interrupt.signal);
10244
+ if (ready2.read.length + ready2.write.length > 0)
10245
+ return { ok: true, kind: "json", json: ready2 };
10246
+ }
10212
10247
  const signal = this.blockingSignal(cancelled, timeoutMs !== 0);
10213
10248
  const selected = await selectFds(this.fds, read, write, timeoutMs, signal);
10214
10249
  return { ok: true, kind: "json", json: selected };
@@ -10406,16 +10441,8 @@ var WasmProcess = class {
10406
10441
  case "proc-spawn":
10407
10442
  this.refuseIfPending(req.restart);
10408
10443
  return { ok: true, kind: "json", json: await this.spawn(req) };
10409
- case "proc-wait": {
10410
- const signal = req.nohang ? this.interrupt.signal : this.blockingSignal(cancelled);
10411
- const flags = {
10412
- untraced: req.untraced,
10413
- continued: req.continued,
10414
- inGroup: this.waitGroup(req.pid)
10415
- };
10416
- const waited = await this.children.wait(req.pid, req.nohang, signal, flags);
10417
- return { ok: true, kind: "json", json: waited };
10418
- }
10444
+ case "proc-wait":
10445
+ return { ok: true, kind: "json", json: await this.procWait(req, cancelled) };
10419
10446
  case "proc-identity":
10420
10447
  return { ok: true, kind: "json", json: await this.options.identity?.() ?? null };
10421
10448
  case "proc-exec": {
@@ -10447,7 +10474,7 @@ var WasmProcess = class {
10447
10474
  return { ok: true, kind: "json", json: this.options.mounts?.() ?? [] };
10448
10475
  case "mount":
10449
10476
  case "umount":
10450
- return this.mountSyscall(req);
10477
+ return this.mountSyscall(req, cancelled);
10451
10478
  case "proc-alarm":
10452
10479
  this.setAlarm(req.sig, req.firstMs ?? req.ms, req.repeat ? req.ms : 0, req.timer);
10453
10480
  return { ok: true, kind: "void" };
@@ -10468,6 +10495,18 @@ var WasmProcess = class {
10468
10495
  return { ok: true, kind: "void" };
10469
10496
  case "dl-reserve":
10470
10497
  return { ok: true, kind: "json", json: this.reserveSlots(req.floor, req.size) };
10498
+ case "dl-load-reserve":
10499
+ return {
10500
+ ok: true,
10501
+ kind: "json",
10502
+ json: { base: this.reserveSlots(req.floor, req.size), handle: this.closures.handle++ }
10503
+ };
10504
+ case "dl-lock":
10505
+ await this.lockDl(req.owner);
10506
+ return { ok: true, kind: "void" };
10507
+ case "dl-unlock":
10508
+ this.unlockDl(req.owner);
10509
+ return { ok: true, kind: "void" };
10471
10510
  case "sig-mask":
10472
10511
  this.caught = req.caught;
10473
10512
  this.ignored = req.ignored;
@@ -10482,14 +10521,14 @@ var WasmProcess = class {
10482
10521
  return { ok: true, kind: "bytes", bytes: this.children.captured(req.pid, req.slot) };
10483
10522
  }
10484
10523
  }
10485
- async mountSyscall(req) {
10524
+ async mountSyscall(req, cancelled) {
10486
10525
  const { mount, umount } = this.options;
10487
10526
  if (!mount || !umount) throw new KernelError("ENOSYS");
10488
10527
  try {
10489
10528
  if (req.op === "umount") await umount(req.target, req.flags);
10490
10529
  else {
10491
10530
  const { source, target, type, flags, data } = req;
10492
- const signal = this.blockingSignal();
10531
+ const signal = this.blockingSignal(cancelled);
10493
10532
  await Promise.race([mount({ source, target, type, flags, data }, signal), aborted(signal)]);
10494
10533
  }
10495
10534
  } catch (err) {
@@ -10504,7 +10543,12 @@ var WasmProcess = class {
10504
10543
  }
10505
10544
  alarm;
10506
10545
  dlLog = [];
10507
- closures = { next: 0, free: [], live: /* @__PURE__ */ new Set() };
10546
+ closures = {
10547
+ next: 0,
10548
+ handle: 1,
10549
+ free: [],
10550
+ live: /* @__PURE__ */ new Set()
10551
+ };
10508
10552
  allocateClosure(floor) {
10509
10553
  const index = this.closures.free.pop() ?? Math.max(floor, this.closures.next);
10510
10554
  this.closures.next = Math.max(this.closures.next, index + 1);
@@ -10517,6 +10561,47 @@ var WasmProcess = class {
10517
10561
  this.closures.free.push(index);
10518
10562
  this.dlLog.push({ kind: "closure-free", index });
10519
10563
  }
10564
+ dlLock = {
10565
+ owner: 0,
10566
+ depth: 0,
10567
+ waiters: /* @__PURE__ */ new Set()
10568
+ };
10569
+ async lockDl(owner) {
10570
+ while (this.dlLock.depth > 0 && this.dlLock.owner !== owner) {
10571
+ if (this.dying) throw new KernelError("EINTR");
10572
+ const interrupt = this.interrupt.signal;
10573
+ const gone = await new Promise((resolve) => {
10574
+ const waiter = {
10575
+ owner,
10576
+ wake: (ended) => {
10577
+ this.dlLock.waiters.delete(waiter);
10578
+ interrupt.removeEventListener("abort", recheck);
10579
+ resolve(ended);
10580
+ }
10581
+ };
10582
+ const recheck = () => waiter.wake(false);
10583
+ this.dlLock.waiters.add(waiter);
10584
+ interrupt.addEventListener("abort", recheck, { once: true });
10585
+ });
10586
+ if (gone) throw new KernelError("EINTR");
10587
+ }
10588
+ this.dlLock.owner = owner;
10589
+ this.dlLock.depth++;
10590
+ }
10591
+ threadEnded(tid) {
10592
+ for (const waiter of [...this.dlLock.waiters]) if (waiter.owner === tid) waiter.wake(true);
10593
+ if (this.dlLock.depth === 0 || this.dlLock.owner !== tid) return;
10594
+ this.dlLock.depth = 0;
10595
+ this.wakeDl();
10596
+ }
10597
+ unlockDl(owner) {
10598
+ if (this.dlLock.depth === 0 || this.dlLock.owner !== owner) throw new KernelError("EPERM");
10599
+ this.dlLock.depth--;
10600
+ if (this.dlLock.depth === 0) this.wakeDl();
10601
+ }
10602
+ wakeDl() {
10603
+ for (const waiter of [...this.dlLock.waiters]) waiter.wake(false);
10604
+ }
10520
10605
  reserveSlots(floor, size) {
10521
10606
  const base = Math.max(floor, this.closures.next);
10522
10607
  this.closures.next = base + size;
@@ -10828,6 +10913,7 @@ function spawnWasmProcess(opts) {
10828
10913
  const endThread = (tid) => {
10829
10914
  const end = threads.get(tid);
10830
10915
  threads.delete(tid);
10916
+ process.threadEnded(tid);
10831
10917
  end?.();
10832
10918
  };
10833
10919
  const spawnThread = (thread) => {