@ai-ecoverse/slicc-kernel 1.31.1 → 1.32.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -125,7 +125,7 @@ const { status, stdout } = await kernel.run(['bash', '-c', 'echo hi'], { cwd: '/
125
125
  kernel.terminate();
126
126
  ```
127
127
 
128
- `createNodeKernel({ root, modules, env, network, worker, processMounts, fstabRetries, cdp, hostfs })` takes the options of `createKernel` except `metadata`, and `fstabRetries`, the delays in ms between tries of a failing `/etc/fstab` line (default `[1000, 4000, 16000]`). `root` is an in-memory directory by default (`memoryRoot()` makes another), which keeps each file in 1 MiB chunks, and POSIX metadata stays in memory. Processes and threads run on `worker_threads`. The kernel has `run`, `openTerminal`, `setRoutes` and `terminate` as above, plus `root`, `writeFile(path, data)` (creating the parent directories) and `readFile(path)` to put files in place and read results. `nodeTransport()` is `fetchTransport()` with Node's `fetch`, which no CORS binds (`crossOrigin: 'any'`). `connect()` and `attachKernel` work as in the browser: the port is a `worker_threads` `MessagePort`, which a worker thread can attach with, and `terminate()` ends every attached client.
128
+ `createNodeKernel({ root, modules, env, network, worker, processMounts, fstabRetries, cdp, hostfs, hostfsOrigin, hostfsFetch })` takes the options of `createKernel` except `metadata`, and `fstabRetries`, the delays in ms between tries of a failing `/etc/fstab` line (default `[1000, 4000, 16000]`). `root` is an in-memory directory by default (`memoryRoot()` makes another), which keeps each file in 1 MiB chunks, and POSIX metadata stays in memory. Processes and threads run on `worker_threads`. The kernel has `run`, `openTerminal`, `setRoutes` and `terminate` as above, plus `root`, `writeFile(path, data)` (creating the parent directories) and `readFile(path)` to put files in place and read results. `nodeTransport()` is `fetchTransport()` with Node's `fetch`, which no CORS binds (`crossOrigin: 'any'`). `connect()` and `attachKernel` work as in the browser: the port is a `worker_threads` `MessagePort`, which a worker thread can attach with, and `terminate()` ends every attached client.
129
129
 
130
130
  ## Commands
131
131
 
@@ -325,7 +325,7 @@ Directories are renamed with `FileSystemHandle.move()` where available, else by
325
325
  - `@ai-ecoverse/slicc-kernel/driver` exports the types and `fsError`.
326
326
  - **Capabilities:** `readonly`, `symlinks`, `chmod`, `ranges`, `maxIo` (the largest read or write per message), `maxFile`, `listingStats`, `linkTimes`, and `attrTtl`/`entryTtl` (in ms, default 1000). `linkTimes` promises that `setattr` with `mtime` on a symlink sets the link's own time. Without it, setting a link's own times (`touch -h`, `utimensat` with `AT_SYMLINK_NOFOLLOW`) fails with `EOPNOTSUPP` and never reaches the target. tmpfs has it; hostfs does not. Setting times through a link follows it in the kernel, so `setattr` gets the target. `ranges` (driver protocol 1.1) promises that a write lands in the file at once, with no copy of the whole file on `open` or `release`, and that `setattr` takes `size`.
327
327
  - **Caching:** the kernel caches attributes and listings for those TTLs, and a driver can push `invalidate` for changes made outside.
328
- - **File contents:** on a mount with `ranges`, as `tmpfs` is, and on the root file system when it is in OPFS (in a browser with sync access handles) or an in-memory root, a program reads and writes a file in 64 KiB pages: reads come from the driver 1 MiB at a time, and changed pages go back in 1 MiB runs once 8 MiB have changed, at `fsync`, and when the file is closed (from the kernel's own descriptors also 250 ms after a write). A file of any size the mount can hold works, with memory to spare for about one more copy of it. In OPFS a write opens a sync access handle for just that write. Elsewhere (`fsa` mounts, `hostfs` with an older proxy, and a root outside OPFS), file contents are read whole when a program opens a file and written back when it closes it. Both give close-to-open consistency, as NFS has.
328
+ - **File contents:** on a mount with `ranges`, as `tmpfs` is, and on the root file system when it is in OPFS (in a browser with sync access handles) or an in-memory root, a program reads and writes a file in 64 KiB pages: reads come from the driver 1 MiB at a time, and changed pages go back in 1 MiB runs once 8 MiB have changed, at `fsync`, and when the file is closed (from the kernel's own descriptors also 250 ms after a write). A file of any size the mount can hold works, with memory to spare for about one more copy of it. In OPFS a write opens a sync access handle for just that write. Elsewhere (`fsa` mounts, `hostfs` with an older proxy, and a root outside OPFS), file contents are read whole when a program opens a file and written back when it closes it. Both give close-to-open consistency, as NFS has. A process that ends without closing its files keeps what it wrote: on exit, on a trap, and when a signal kills it. For a killed WASI program, the kernel lets its worker write its changed pages back before `waitpid` reports the death. The only exception is a worker that makes no call into the kernel for 2 seconds after the signal; it is stopped without that write-back.
329
329
  - **Rules on a mount:**
330
330
  - a rename across mounts is `EXDEV`, so `mv` copies;
331
331
  - `df` reports each mount from its driver's `statfs`;
@@ -373,7 +373,8 @@ A `hostfs` mount is a folder exported by the local proxy ([slicc-node](https://g
373
373
  - **Options:** `ro` mounts read-only, and `maxfile` caps file size as on any mount. There is no other size limit: reads and writes go in `maxIo` pieces (16 MiB unless the proxy says otherwise), so a file of any size the host can hold works. A proxy that advertises `ranges` in its grant (slicc-node 2.4, slicc-swift 1.4) makes the mount ranged, so programs read and write its files in pages and truncate through `setattr` `size`. With an older proxy files go whole, and a truncate on a ranged mount whose proxy was replaced by an older one is done by rewriting the file.
374
374
  - **Changes on the host** arrive as invalidations on a watch stream (`POST /api/hostfs/watch`), so an edit made outside shows up on the next access without a remount.
375
375
  - **Proxy loss:** when the watch stream ends, or is silent for 45 s, the kernel reconnects once at once. If that fails, the mount is `nomedium`, as with [removable media](#removable-media-fsa): operations fail with `ENOMEDIUM` (`ENODEV` for WASI), and the kernel keeps reconnecting with backoff (1 s to 30 s), so the folder comes back by itself when the proxy does. A mount made while the proxy is down starts as `nomedium`, and so does one whose grant or first watch takes more than 15 s; it comes up by itself when they arrive.
376
- - **Consistency:** a file that changes on the host while a program reads it fails with `ESTALE`, and the kernel restarts the whole read (up to 3 times) instead of stitching two versions together. On a ranged mount an open file keeps the version it was opened at (its ETag) until it writes, also when it is handed to a child process: a page fetched after the host changed the file fails the program's read with `ESTALE` ("Stale file handle") instead of mixing versions.
376
+ - **Consistency:** a file that changes on the host while a program reads it fails with `ESTALE`, and the kernel restarts the whole read (up to 3 times) instead of stitching two versions together. On a ranged mount an open file keeps the version it was opened at (its ETag) until it writes, also when it is handed to a child process: a page fetched after the host changed the file fails the program's read with `ESTALE` ("Stale file handle") instead of mixing versions. A file deleted on the host while a program holds it open fails the next read that goes to the host with `ENOENT`, not with NFS's `ESTALE`.
377
+ - **Node:** a page's requests carry its `Origin`, and slicc-node grants a token to that origin only. Node's `fetch` sends none, so a Node embedder passes `createNodeKernel` the origin its `hostfs` hook asked for the grant with, as `hostfsOrigin`, and the kernel adds it to every hostfs request. Without it the mount stays `nomedium`. `hostfsFetch(url, init)` replaces the `fetch` those requests use (for an agent or a test proxy), and gets the `Origin` too when both are given.
377
378
 
378
379
  ### Mounting from a program
379
380
 
@@ -5976,6 +5976,7 @@ var SAB_I_TIMERS = 9;
5976
5976
  var SAB_I_MEMORY = 10;
5977
5977
  var SAB_I_TOTAL_HI = 11;
5978
5978
  var SAB_I_OFFSET_HI = 12;
5979
+ var SAB_I_KILLED = 13;
5979
5980
  var U32 = 2 ** 32;
5980
5981
  function storeU53(header2, lo, hi, value) {
5981
5982
  Atomics.store(header2, hi, Math.floor(value / U32));
@@ -7817,6 +7818,7 @@ function isWasmSyscall(req) {
7817
7818
  return typeof op === "string" && SYSCALL_OPS.has(op);
7818
7819
  }
7819
7820
  var MAX_READ = 1024 * 1024;
7821
+ var DYING = { ok: false, errno: "EINTR", message: "the process is being killed" };
7820
7822
  var HOSTS_ONLY = new Resolver({ routes: new Routes() });
7821
7823
  var WasmProcess = class {
7822
7824
  exited = false;
@@ -7825,6 +7827,7 @@ var WasmProcess = class {
7825
7827
  ignored = 0;
7826
7828
  inherited = 0;
7827
7829
  interrupt = new AbortController();
7830
+ dying = false;
7828
7831
  execChild;
7829
7832
  execTermsig;
7830
7833
  stopped = 0;
@@ -7857,7 +7860,10 @@ var WasmProcess = class {
7857
7860
  signal(sig) {
7858
7861
  if (this.execChild !== void 0) {
7859
7862
  void Promise.resolve(this.options.kill?.(this.execChild, sig)).catch(() => void 0);
7860
- return sig === SIG.KILL ? "terminate" : "forward";
7863
+ if (sig !== SIG.KILL) return "forward";
7864
+ if (!this.options.writesBack?.(this.execChild)) return "terminate";
7865
+ this.wake?.();
7866
+ return "forward";
7861
7867
  }
7862
7868
  if (sig === SIG.KILL) return "terminate";
7863
7869
  if (sig === SIG.CONT) this.cont();
@@ -7901,14 +7907,22 @@ var WasmProcess = class {
7901
7907
  this.options.onSyscall?.(req);
7902
7908
  for (; ; ) {
7903
7909
  await this.resumed;
7910
+ if (this.dying) return DYING;
7904
7911
  const stops = this.stops;
7905
7912
  const result = await this.dispatch(req);
7913
+ if (this.dying) return DYING;
7906
7914
  const restart = !result.ok && result.errno === "EINTR" && this.stops !== stops;
7907
7915
  if (restart && !this.options.hasPending?.()) continue;
7908
7916
  await this.resumed;
7909
7917
  return result;
7910
7918
  }
7911
7919
  }
7920
+ die() {
7921
+ if (this.dying) return;
7922
+ this.dying = true;
7923
+ this.interrupt.abort();
7924
+ this.wake?.();
7925
+ }
7912
7926
  async dispatch(req) {
7913
7927
  try {
7914
7928
  if (isFdSyscall(req)) return await this.fdSyscall(req);
@@ -8369,6 +8383,7 @@ var WASM_MAX_THREADS = 256;
8369
8383
 
8370
8384
  // src/kernel/host.ts
8371
8385
  var CRASHED = 70;
8386
+ var KILL_GRACE_MS = 2e3;
8372
8387
  var descIds = /* @__PURE__ */ new WeakMap();
8373
8388
  var nextDescId = 1;
8374
8389
  function descId(file) {
@@ -8392,6 +8407,41 @@ function inheritedFds(fds) {
8392
8407
  };
8393
8408
  });
8394
8409
  }
8410
+ var Dying = class {
8411
+ ending;
8412
+ timer;
8413
+ busy = 0;
8414
+ finish;
8415
+ start(code, sig, header2, process, finish) {
8416
+ if (this.ending) return;
8417
+ this.ending = { code, sig };
8418
+ this.finish = finish;
8419
+ this.arm();
8420
+ Atomics.store(header2, SAB_I_KILLED, code);
8421
+ process.die();
8422
+ }
8423
+ async serving(work) {
8424
+ this.busy++;
8425
+ clearTimeout(this.timer);
8426
+ try {
8427
+ return await work();
8428
+ } finally {
8429
+ this.busy--;
8430
+ this.arm();
8431
+ }
8432
+ }
8433
+ arm() {
8434
+ clearTimeout(this.timer);
8435
+ if (this.busy === 0 && this.finish) this.timer = setTimeout(this.finish, KILL_GRACE_MS);
8436
+ }
8437
+ clear() {
8438
+ clearTimeout(this.timer);
8439
+ }
8440
+ status(reported) {
8441
+ if (this.ending) return [this.ending.code, this.ending.sig];
8442
+ return [typeof reported === "number" ? reported : CRASHED, void 0];
8443
+ }
8444
+ };
8395
8445
  function spawnWasmProcess(opts) {
8396
8446
  const sab = new SharedArrayBuffer(SAB_HEADER_BYTES + SAB_DEFAULT_WINDOW_BYTES);
8397
8447
  const header2 = new Int32Array(sab, 0, SAB_HEADER_I32);
@@ -8403,6 +8453,7 @@ function spawnWasmProcess(opts) {
8403
8453
  forker: opts.forker,
8404
8454
  fs: opts.fs,
8405
8455
  kill: opts.kill,
8456
+ ...opts.writesBack ? { writesBack: opts.writesBack } : {},
8406
8457
  processes: opts.processes,
8407
8458
  openFiles: opts.openFiles,
8408
8459
  ...opts.nodes ? { nodes: opts.nodes } : {},
@@ -8433,18 +8484,16 @@ function spawnWasmProcess(opts) {
8433
8484
  renamed: (from, to) => holds.renamed(from, to)
8434
8485
  });
8435
8486
  const worker = opts.createWorker();
8436
- const dispatch2 = async (req) => {
8437
- if (isWasmSyscall(req)) return process.syscall(req);
8438
- return dispatchSyncFs(req);
8439
- };
8487
+ const dying = new Dying();
8488
+ const dispatch2 = (req) => dying.serving(() => isWasmSyscall(req) ? process.syscall(req) : dispatchSyncFs(req));
8440
8489
  const responder = attachSyncSabResponder(worker, sab, token, { dispatch: dispatch2 });
8441
- let settle3;
8442
- const exited = new Promise((resolve) => settle3 = resolve);
8490
+ const { promise: exited, resolve: settle3 } = Promise.withResolvers();
8443
8491
  let done4 = false;
8444
8492
  let endedBy;
8445
8493
  const finish = (code, sig) => {
8446
8494
  if (done4) return;
8447
8495
  done4 = true;
8496
+ dying.clear();
8448
8497
  endedBy = sig ?? process.execTermsig;
8449
8498
  worker.removeEventListener("message", onMessage);
8450
8499
  worker.removeEventListener("error", onError);
@@ -8460,10 +8509,10 @@ function spawnWasmProcess(opts) {
8460
8509
  };
8461
8510
  const handle = (data) => {
8462
8511
  if (data?.type === WASM_PROCESS_EXIT) {
8463
- finish(typeof data.code === "number" ? data.code : CRASHED);
8512
+ finish(...dying.status(data.code));
8464
8513
  } else if (data?.type === WASM_PROCESS_ERROR) {
8465
- opts.onError?.(String(data.message));
8466
- finish(CRASHED);
8514
+ if (!dying.ending) opts.onError?.(String(data.message));
8515
+ finish(...dying.status(CRASHED));
8467
8516
  } else if (data?.type === WASM_THREAD_SPAWN && data.thread) {
8468
8517
  spawnThread(data.thread);
8469
8518
  }
@@ -8533,7 +8582,10 @@ function spawnWasmProcess(opts) {
8533
8582
  };
8534
8583
  worker.postMessage(init, opts.fork ? [opts.fork.memory.buffer] : []);
8535
8584
  const signal = (sig) => {
8536
- if (process.signal(sig) === "terminate") finish(sig === SIG.KILL ? 137 : 128 + sig, sig);
8585
+ if (process.signal(sig) !== "terminate") return;
8586
+ const code = sig === SIG.KILL ? 137 : 128 + sig;
8587
+ if (opts.program.abi !== "wasi" || done4) finish(code, sig);
8588
+ else dying.start(code, sig, header2, process, () => finish(code, sig));
8537
8589
  };
8538
8590
  const kill = (code = 137) => finish(code, code > 128 && code < 160 ? code - 128 : void 0);
8539
8591
  return {
@@ -8544,7 +8596,8 @@ function spawnWasmProcess(opts) {
8544
8596
  termsig: () => endedBy,
8545
8597
  onState: (listener) => process.onState(listener),
8546
8598
  memory: () => Atomics.load(header2, SAB_I_MEMORY) * 65536,
8547
- ignoredSignals: (fork) => process.inheritable(fork)
8599
+ ignoredSignals: (fork) => process.inheritable(fork),
8600
+ writesBack: () => opts.program.abi === "wasi" && !done4
8548
8601
  };
8549
8602
  }
8550
8603
 
@@ -10273,6 +10326,7 @@ var Launcher = class {
10273
10326
  spawner: this.spawner(pid, req.report),
10274
10327
  forker: this.forker(pid, req),
10275
10328
  kill: (target, sig) => this.kill(target, sig),
10329
+ writesBack: (target) => this.processes.get(target)?.writesBack() === true,
10276
10330
  processes: () => ({ boot: this.boot, processes: this.list() }),
10277
10331
  openFiles: this.openFiles,
10278
10332
  nodes: this.nodes,