@ai-ecoverse/slicc-kernel 1.20.1 → 1.21.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.
package/README.md CHANGED
@@ -217,6 +217,20 @@ Directories are renamed with `FileSystemHandle.move()` where available, else by
217
217
  - `options.ro` makes any mount read-only: writes fail with `EROFS`;
218
218
  - a driver that crashes or doesn't answer within 30 s makes its mount's calls fail with `EIO`, and the mount is listed as `failed`.
219
219
 
220
+ ### `/etc/fstab`
221
+
222
+ At boot the kernel mounts what `/etc/fstab` lists, in fstab(5) format: `source target type options` (the dump and pass fields are ignored), with `#` comments and `\040` for a space in a field. Options are those of `mount -o`, and lines with `noauto` are left out.
223
+
224
+ ```
225
+ none /mnt/scratch tmpfs maxfile=1G 0 0
226
+ fsa:<id> /mnt/folder fsa rw 0 0
227
+ project /mnt/project hostfs ro 0 0
228
+ ```
229
+
230
+ - **Boot never waits for it:** the lines are mounted in the background once the kernel has started, all at once, so a slow source holds up nothing.
231
+ - **Retries:** a line that fails is tried again after 1, 4 and 16 seconds (a driver package installed meanwhile, a mount point created later), except when it cannot succeed (`EINVAL`, `EBUSY`, `EPERM`, `EACCES`). `terminate()` stops the retries.
232
+ - **No user involvement:** an `fsa` line names a drive by its id: it comes up with the folder it had while its permission holds, and otherwise with no medium, asking the page for the folder through `onMountPending`, as any `fsa` mount does. With `media: false` the drive finds its folder only within the same kernel. With the default `media`, an `fsa` line reads its folder handle back from IndexedDB at boot, which crashes Chrome in an Incognito or other off-the-record profile ([#95](https://github.com/ai-ecoverse/slicc-kernel/issues/95)); a page cannot reliably tell it runs in one, so an embedder that cannot rule it out passes `media: false`, which keeps `fsa` lines in `/etc/fstab` safe. A `hostfs` line waits only for its first connection, and comes up with no medium if the proxy is not there yet.
233
+
220
234
  ### Removable media: `fsa`
221
235
 
222
236
  An `fsa` mount is a drive for a local folder, from the File System Access API, and the folder is its medium.
@@ -321,6 +335,8 @@ Unit tests live in `test/unit/`, which stays out of git. They run real wasm bash
321
335
 
322
336
  The Biome, TypeScript, lefthook, Renovate and CI configuration comes from [slicc-shared-web](https://github.com/ai-ecoverse/slicc-shared-web), which also provides the `slicc-lint-comments` (no comments anywhere), `slicc-no-unit-tests` (no unit tests in git) and `slicc-diff-cover` (100% coverage of changed lines) commands that `npm run lint` and the pre-commit hook use.
323
337
 
338
+ `tools/big-file.mjs [bytes]` is a manual check, not part of any suite. It writes a file of that size (64 MiB by default) through a tmpfs mount with the unit-test launcher, reads it back, and prints the peak RSS and its ratio to the file size. A whole file is still held in memory several times over (#86), so a 1.1 GiB file peaked at 18 GB of RSS. **Do not run it past 1 GiB on a workstation.** It refuses anything over 256 MiB unless `SLICC_BIG_FILE_I_HAVE_THE_RAM=1` is set. Measure a small file and extrapolate instead.
339
+
324
340
  Releases are cut by semantic-release on every push to `main`. A failure after the version is tagged (a rejected push, a failed `npm publish`) doesn't strand that version: `tools/release-recover.mjs` runs next. For any version tagged at HEAD, it first pushes the tag if origin lacks it, then publishes the version if npm lacks it, with retries, and creates its GitHub release if that is missing too. Re-running the Release workflow completes such a version the same way.
325
341
 
326
342
  ## License
@@ -748,7 +748,7 @@ var KernelSocket = class _KernelSocket {
748
748
  const own = this.changes.wait(signal);
749
749
  if (this.state !== "connected" || !this.link) return own;
750
750
  const waits = [own, this.link.rx.changed(signal), this.link.tx.changed(signal)];
751
- for (const wait of waits) wait.catch(() => void 0);
751
+ for (const wait2 of waits) wait2.catch(() => void 0);
752
752
  return pipeCall(() => Promise.race(waits));
753
753
  }
754
754
  bind(addr) {
@@ -4672,6 +4672,13 @@ var SAB_I_PUB = 7;
4672
4672
  var SAB_I_SIGNALS = 8;
4673
4673
  var SAB_I_TIMERS = 9;
4674
4674
  var SAB_I_MEMORY = 10;
4675
+ var SAB_I_TOTAL_HI = 11;
4676
+ var SAB_I_OFFSET_HI = 12;
4677
+ var U32 = 2 ** 32;
4678
+ function storeU53(header2, lo, hi, value) {
4679
+ Atomics.store(header2, hi, Math.floor(value / U32));
4680
+ Atomics.store(header2, lo, value % U32 | 0);
4681
+ }
4675
4682
  var SAB_STATE_READY = 2;
4676
4683
  var SAB_STATUS_BYTES = 1;
4677
4684
  var SAB_STATUS_JSON = 2;
@@ -4731,9 +4738,9 @@ function attachSyncSabResponder(port, sab, token, opts) {
4731
4738
  const chunk2 = Math.min(window.byteLength, entry.payload.byteLength - offset);
4732
4739
  if (chunk2 > 0) window.set(entry.payload.subarray(offset, offset + chunk2));
4733
4740
  Atomics.store(header2, SAB_I_STATUS, entry.status);
4734
- Atomics.store(header2, SAB_I_TOTAL, entry.payload.byteLength);
4741
+ storeU53(header2, SAB_I_TOTAL, SAB_I_TOTAL_HI, entry.payload.byteLength);
4735
4742
  Atomics.store(header2, SAB_I_CHUNK, chunk2);
4736
- Atomics.store(header2, SAB_I_OFFSET, offset);
4743
+ storeU53(header2, SAB_I_OFFSET, SAB_I_OFFSET_HI, offset);
4737
4744
  Atomics.store(header2, SAB_I_SEQ, id);
4738
4745
  Atomics.store(header2, SAB_I_STATE, SAB_STATE_READY);
4739
4746
  Atomics.add(header2, SAB_I_PUB, 1);
@@ -7201,10 +7208,164 @@ function removableMedium(onLost, open = (handle) => new OpfsFs(handle)) {
7201
7208
  };
7202
7209
  }
7203
7210
 
7211
+ // src/mount/syscall.ts
7212
+ var MS_RDONLY = 1;
7213
+ var MS_MGC_MSK = 4294901760;
7214
+ var MS_MGC_VAL = 3236757504;
7215
+ var IGNORED_FLAGS = 2 | 4 | 8 | 16 | 128 | 1024 | 2048 | 32768 | 1 << 21 | 1 << 24 | 1 << 25;
7216
+ var MNT_FORCE = 1;
7217
+ var MNT_DETACH = 2;
7218
+ var UMOUNT_NOFOLLOW = 8;
7219
+ var GENERIC = /* @__PURE__ */ new Set([
7220
+ "defaults",
7221
+ "async",
7222
+ "sync",
7223
+ "dirsync",
7224
+ "atime",
7225
+ "noatime",
7226
+ "diratime",
7227
+ "nodiratime",
7228
+ "relatime",
7229
+ "norelatime",
7230
+ "strictatime",
7231
+ "nostrictatime",
7232
+ "lazytime",
7233
+ "nolazytime",
7234
+ "suid",
7235
+ "nosuid",
7236
+ "dev",
7237
+ "nodev",
7238
+ "exec",
7239
+ "noexec",
7240
+ "auto",
7241
+ "noauto",
7242
+ "user",
7243
+ "nouser",
7244
+ "users",
7245
+ "nofail",
7246
+ "_netdev",
7247
+ "silent",
7248
+ "loud"
7249
+ ]);
7250
+ function parseMountOptions(data) {
7251
+ const options = {};
7252
+ for (const raw of data.split(",")) {
7253
+ const item = raw.trim();
7254
+ if (!item || GENERIC.has(item)) continue;
7255
+ if (item === "rw") {
7256
+ delete options.ro;
7257
+ continue;
7258
+ }
7259
+ const eq = item.indexOf("=");
7260
+ if (eq === 0) throw errnoError("EINVAL", `bad mount option ${item}`);
7261
+ if (eq < 0) options[item] = "";
7262
+ else options[item.slice(0, eq)] = item.slice(eq + 1);
7263
+ }
7264
+ if (options.maxfile !== void 0) parseSize(options.maxfile);
7265
+ return options;
7266
+ }
7267
+ function refusedTarget(target) {
7268
+ const at = normalizePath(target);
7269
+ const kernel = at === "/" || [`/proc`, `/dev`].some((k) => at === k || at.startsWith(`${k}/`));
7270
+ if (kernel) throw errnoError("EBUSY", `${at} belongs to the kernel`);
7271
+ }
7272
+ function absolute(target) {
7273
+ if (!target) throw errnoError("ENOENT", "no mount point");
7274
+ if (!target.startsWith("/")) throw errnoError("EINVAL", `not an absolute path: ${target}`);
7275
+ return normalizePath(target);
7276
+ }
7277
+ function mountCall(call) {
7278
+ let flags = call.flags >>> 0;
7279
+ if ((flags & MS_MGC_MSK) >>> 0 === MS_MGC_VAL) flags &= ~MS_MGC_MSK;
7280
+ if (flags & ~(MS_RDONLY | IGNORED_FLAGS)) {
7281
+ throw errnoError("EINVAL", `unsupported mount flags ${flags.toString(16)}`);
7282
+ }
7283
+ if (!call.type) throw errnoError("EINVAL", "no file system type");
7284
+ const target = absolute(call.target);
7285
+ refusedTarget(target);
7286
+ const options = parseMountOptions(call.data);
7287
+ if (flags & MS_RDONLY) options.ro = "";
7288
+ return { type: call.type, source: call.source || "none", target, options };
7289
+ }
7290
+ function umountCall(target, flags) {
7291
+ const known = MNT_FORCE | MNT_DETACH | UMOUNT_NOFOLLOW;
7292
+ if (flags >>> 0 & ~known) throw errnoError("EINVAL", `unsupported umount flags ${flags}`);
7293
+ const at = absolute(target);
7294
+ refusedTarget(at);
7295
+ return { target: at, detach: (flags & (MNT_FORCE | MNT_DETACH)) !== 0 };
7296
+ }
7297
+
7298
+ // src/mount/fstab.ts
7299
+ var FSTAB_PATH = "/etc/fstab";
7300
+ var FSTAB_RETRIES = [1e3, 4e3, 16e3];
7301
+ var FINAL = /* @__PURE__ */ new Set(["EINVAL", "EBUSY", "EPERM", "EACCES"]);
7302
+ function unescape(field) {
7303
+ return field.replace(
7304
+ /\\([0-7]{3})/g,
7305
+ (_, octal) => String.fromCharCode(Number.parseInt(octal, 8))
7306
+ );
7307
+ }
7308
+ function parseFstab(text) {
7309
+ const lines = [];
7310
+ text.split("\n").forEach((raw, index) => {
7311
+ const content = raw.replace(/#.*$/, "").trim();
7312
+ if (!content) return;
7313
+ const [source, target, type, options = "defaults"] = content.split(/\s+/).map(unescape);
7314
+ if (source === void 0 || target === void 0 || type === void 0) return;
7315
+ if (options.split(",").includes("noauto")) return;
7316
+ const spec = { source, target, type, options: {} };
7317
+ try {
7318
+ lines.push({ line: index + 1, spec: { ...spec, options: parseMountOptions(options) } });
7319
+ } catch (err) {
7320
+ lines.push({ line: index + 1, spec, invalid: err });
7321
+ }
7322
+ });
7323
+ return lines;
7324
+ }
7325
+ function codeOf3(err) {
7326
+ const code = err?.code;
7327
+ return typeof code === "string" ? code : "EIO";
7328
+ }
7329
+ function wait(ms, signal) {
7330
+ if (signal.aborted) return Promise.resolve();
7331
+ return new Promise((resolve) => {
7332
+ const done4 = () => {
7333
+ clearTimeout(timer);
7334
+ signal.removeEventListener("abort", done4);
7335
+ resolve();
7336
+ };
7337
+ const timer = setTimeout(done4, ms);
7338
+ signal.addEventListener("abort", done4, { once: true });
7339
+ });
7340
+ }
7341
+ async function mountFstab(lines, mount, signal, retries = FSTAB_RETRIES, discard = () => {
7342
+ }) {
7343
+ return Promise.all(
7344
+ lines.map(async ({ line, spec, invalid }) => {
7345
+ let failed = invalid;
7346
+ for (let attempt = 0; !invalid && attempt <= retries.length && !signal.aborted; attempt++) {
7347
+ try {
7348
+ const entry = await mount(spec);
7349
+ if (!signal.aborted) return { line, target: spec.target, entry };
7350
+ discard(entry);
7351
+ failed = Object.assign(new Error("the kernel stopped"), { code: "ECANCELED" });
7352
+ break;
7353
+ } catch (err) {
7354
+ failed = err;
7355
+ if (FINAL.has(codeOf3(err)) || attempt === retries.length) break;
7356
+ await wait(retries[attempt], signal);
7357
+ }
7358
+ }
7359
+ const code = codeOf3(failed);
7360
+ return { line, target: spec.target, code, error: String(failed?.message ?? code) };
7361
+ })
7362
+ );
7363
+ }
7364
+
7204
7365
  // src/mount/hostfs.ts
7205
7366
  var HOSTFS_IO = 16 * 1024 * 1024;
7206
- var codeOf3 = (err) => err?.code;
7207
- var unreachable = (err) => codeOf3(err) === "ENOTCONN";
7367
+ var codeOf4 = (err) => err?.code;
7368
+ var unreachable = (err) => codeOf4(err) === "ENOTCONN";
7208
7369
  function rel(path) {
7209
7370
  return path.replace(/^\/+/, "");
7210
7371
  }
@@ -7339,7 +7500,7 @@ function hostfsHandlers(current) {
7339
7500
  try {
7340
7501
  return { opened: await opening(), restore: void 0 };
7341
7502
  } catch (err) {
7342
- if (!flags.write || codeOf3(err) !== "EACCES") throw err;
7503
+ if (!flags.write || codeOf4(err) !== "EACCES") throw err;
7343
7504
  const mode = (await client.call("stat", { path: rel(path) })).mode ?? 0;
7344
7505
  if (mode & 128) throw err;
7345
7506
  const restore = { path: rel(path), mode };
@@ -7428,7 +7589,7 @@ function keepWatching(loop, signal) {
7428
7589
  const uncap = () => clearTimeout(capped);
7429
7590
  void ready2.promise.then(uncap);
7430
7591
  signal.addEventListener("abort", uncap, { once: true });
7431
- let wait = first;
7592
+ let wait2 = first;
7432
7593
  let up = false;
7433
7594
  let current;
7434
7595
  async function session() {
@@ -7473,7 +7634,7 @@ function keepWatching(loop, signal) {
7473
7634
  const opened2 = await session();
7474
7635
  if (signal.aborted) return;
7475
7636
  if (opened2 && Date.now() - began >= first) {
7476
- wait = first;
7637
+ wait2 = first;
7477
7638
  continue;
7478
7639
  }
7479
7640
  if (!opened2 && up) {
@@ -7481,8 +7642,8 @@ function keepWatching(loop, signal) {
7481
7642
  loop.down();
7482
7643
  }
7483
7644
  ready2.resolve();
7484
- await sleep(wait, signal);
7485
- wait = Math.min(wait * 2, most);
7645
+ await sleep(wait2, signal);
7646
+ wait2 = Math.min(wait2 * 2, most);
7486
7647
  }
7487
7648
  })();
7488
7649
  return { ready: ready2.promise, kick: () => current?.abort() };
@@ -7537,93 +7698,6 @@ async function openHostfs(spec, hook, fetch2, timing = {}) {
7537
7698
  };
7538
7699
  }
7539
7700
 
7540
- // src/mount/syscall.ts
7541
- var MS_RDONLY = 1;
7542
- var MS_MGC_MSK = 4294901760;
7543
- var MS_MGC_VAL = 3236757504;
7544
- var IGNORED_FLAGS = 2 | 4 | 8 | 16 | 128 | 1024 | 2048 | 32768 | 1 << 21 | 1 << 24 | 1 << 25;
7545
- var MNT_FORCE = 1;
7546
- var MNT_DETACH = 2;
7547
- var UMOUNT_NOFOLLOW = 8;
7548
- var GENERIC = /* @__PURE__ */ new Set([
7549
- "defaults",
7550
- "async",
7551
- "sync",
7552
- "dirsync",
7553
- "atime",
7554
- "noatime",
7555
- "diratime",
7556
- "nodiratime",
7557
- "relatime",
7558
- "norelatime",
7559
- "strictatime",
7560
- "nostrictatime",
7561
- "lazytime",
7562
- "nolazytime",
7563
- "suid",
7564
- "nosuid",
7565
- "dev",
7566
- "nodev",
7567
- "exec",
7568
- "noexec",
7569
- "auto",
7570
- "noauto",
7571
- "user",
7572
- "nouser",
7573
- "users",
7574
- "nofail",
7575
- "_netdev",
7576
- "silent",
7577
- "loud"
7578
- ]);
7579
- function parseMountOptions(data) {
7580
- const options = {};
7581
- for (const raw of data.split(",")) {
7582
- const item = raw.trim();
7583
- if (!item || GENERIC.has(item)) continue;
7584
- if (item === "rw") {
7585
- delete options.ro;
7586
- continue;
7587
- }
7588
- const eq = item.indexOf("=");
7589
- if (eq === 0) throw errnoError("EINVAL", `bad mount option ${item}`);
7590
- if (eq < 0) options[item] = "";
7591
- else options[item.slice(0, eq)] = item.slice(eq + 1);
7592
- }
7593
- if (options.maxfile !== void 0) parseSize(options.maxfile);
7594
- return options;
7595
- }
7596
- function refusedTarget(target) {
7597
- const at = normalizePath(target);
7598
- const kernel = at === "/" || [`/proc`, `/dev`].some((k) => at === k || at.startsWith(`${k}/`));
7599
- if (kernel) throw errnoError("EBUSY", `${at} belongs to the kernel`);
7600
- }
7601
- function absolute(target) {
7602
- if (!target) throw errnoError("ENOENT", "no mount point");
7603
- if (!target.startsWith("/")) throw errnoError("EINVAL", `not an absolute path: ${target}`);
7604
- return normalizePath(target);
7605
- }
7606
- function mountCall(call) {
7607
- let flags = call.flags >>> 0;
7608
- if ((flags & MS_MGC_MSK) >>> 0 === MS_MGC_VAL) flags &= ~MS_MGC_MSK;
7609
- if (flags & ~(MS_RDONLY | IGNORED_FLAGS)) {
7610
- throw errnoError("EINVAL", `unsupported mount flags ${flags.toString(16)}`);
7611
- }
7612
- if (!call.type) throw errnoError("EINVAL", "no file system type");
7613
- const target = absolute(call.target);
7614
- refusedTarget(target);
7615
- const options = parseMountOptions(call.data);
7616
- if (flags & MS_RDONLY) options.ro = "";
7617
- return { type: call.type, source: call.source || "none", target, options };
7618
- }
7619
- function umountCall(target, flags) {
7620
- const known = MNT_FORCE | MNT_DETACH | UMOUNT_NOFOLLOW;
7621
- if (flags >>> 0 & ~known) throw errnoError("EINVAL", `unsupported umount flags ${flags}`);
7622
- const at = absolute(target);
7623
- refusedTarget(at);
7624
- return { target: at, detach: (flags & (MNT_FORCE | MNT_DETACH)) !== 0 };
7625
- }
7626
-
7627
7701
  // src/mount/tmpfs.ts
7628
7702
  function tmpfs() {
7629
7703
  let inodes = 0;
@@ -8042,6 +8116,8 @@ var Launcher = class {
8042
8116
  openFiles = /* @__PURE__ */ new Set();
8043
8117
  held = /* @__PURE__ */ new Set();
8044
8118
  processMounts;
8119
+ booting = new AbortController();
8120
+ fstab = Promise.resolve([]);
8045
8121
  nextPid = 1e3;
8046
8122
  boot = Date.now();
8047
8123
  terminals = 0;
@@ -8449,6 +8525,7 @@ var Launcher = class {
8449
8525
  for (const holds of this.held) holds.revoke(at);
8450
8526
  }
8451
8527
  unmountAll() {
8528
+ this.booting.abort();
8452
8529
  const targets = this.mounts.list().map((m) => m.target);
8453
8530
  for (const target of targets.sort((a, b) => b.length - a.length)) this.umount(target, true);
8454
8531
  }
@@ -8480,6 +8557,17 @@ var Launcher = class {
8480
8557
  async prepare() {
8481
8558
  for (const dir of SHARED_DIRS) await this.base.mkdir(dir, { recursive: true });
8482
8559
  await writeCaFile(this.base, this.ca).catch(() => void 0);
8560
+ this.fstab = this.mountFstab();
8561
+ }
8562
+ async mountFstab() {
8563
+ const text = await this.base.readFile(FSTAB_PATH).catch(() => "");
8564
+ return mountFstab(
8565
+ parseFstab(text),
8566
+ (spec) => this.mount(spec),
8567
+ this.booting.signal,
8568
+ FSTAB_RETRIES,
8569
+ (entry) => this.umount(entry.target, true)
8570
+ );
8483
8571
  }
8484
8572
  environment(cwd, extra) {
8485
8573
  return {