@ai-ecoverse/slicc-kernel 1.133.3 → 1.133.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
@@ -709,7 +709,7 @@ The proxy answers `200` with `Content-Type: application/vnd.slicc.raw-fetch`: a
709
709
 
710
710
  `/` is the OPFS root. Each process mounts the top-level directories that exist when it starts (plus `/usr` and `/bin`), so files in them are shared by all processes and visible through the OPFS API as soon as the process that wrote them has closed them; `run` resolves only after that. `createKernel` creates `/tmp` and `/home` in OPFS, so they are shared too. Files directly in `/`, and directories created there after a process started, are OPFS as well, the same for Emscripten and WASI programs; only `/dev` and `/proc` live in each process's memory. A program's `mkdir` (WASI `path_create_directory`, Emscripten's `mkdir`) is exclusive, as on Linux: it fails with `EEXIST` when the name exists, and of several processes or threads making one name at once exactly one succeeds, so a directory works as a lock. Under a missing directory it fails with `ENOENT`, as on Linux (`mkdir -p` and `os.makedirs` make the parents in the program); a JS program's `ctx.fs.mkdir` makes parents itself. The kernel makes the system `PNPM_HOME` (`/usr/local/share/pnpm`, or the embedder's) when it starts, since pnpm makes only its `bin` directory there. A full origin storage (`QuotaExceededError`, which also comes early in a browser's private or off-the-record contexts, whatever `navigator.storage.estimate()` says) is `ENOSPC`, never retried. A read that meets `NotFoundError` while a writer outside the kernel swaps the file in with `createWritable` (and a `stat`, `lstat` or `utimes` that meets it) looks the name up again, up to three times, before it fails ([#1075](https://github.com/ai-ecoverse/slicc-kernel/issues/1075)), so a program whose package is overwritten in place keeps starting. An OPFS or IndexedDB failure that has no POSIX counterpart is `EIO`; its message names the DOM error and the operation (`lookup`, `read`, `write`, `commit` or `sidecar`), and the kernel worker logs the first of each kind to its console. File contents are buffered per open file and written back on close, `fsync` and exit, so a refused write (`ENOSPC`, `EIO`) surfaces at that close or `fsync`, and a program that ignores close errors doesn't see it (`echo x > f` can exit 0 with `f` empty, as with any buffered write-back). A write-back error a close has already reported to a WASI program isn't raised again when it exits, so the program keeps its own exit status. Metadata operations (`mkdir`, `rename`, `rm`, …) go straight to OPFS. The kernel worker is the only writer.
711
711
 
712
- **Renaming a directory on the OPFS root** ([#1122](https://github.com/ai-ecoverse/slicc-kernel/issues/1122)): Chrome's OPFS can move a file but not a directory. So the kernel renames a directory by creating the new tree and moving each file into it (copying a file only if it can't move, e.g. while it's open elsewhere), then removing the old tree. Within the kernel the rename is atomic to readers: a lookup or listing under the old or the new name, or of either parent, waits until the whole tree has moved. So a stage-then-rename updater (pnpm's, an atomic config write) is never seen half there. Another tab sharing the root reads OPFS directly and can see the tree mid-move. A root whose directories can move (the Node memory root) renames them in one step. **If the page reloads or crashes mid-rename**, some files are already in the new tree and the rest are still in the old one. So before the first move, the kernel writes the rename's intent (`{ from, to }`) to its metadata store, and clears it after the last. At the next boot, when no other tab shares the root (the same rule as for the boot's other repair of stale metadata), the kernel finishes every recorded rename: it moves what's left into the new tree, removes the old name, and clears the record. While another tab shares the root, the split tree stays as it is (both names visible, each with part of the files) until a boot that's alone.
712
+ **Renaming a directory on the OPFS root** ([#1122](https://github.com/ai-ecoverse/slicc-kernel/issues/1122)): Chrome's OPFS can move a file but not a directory. So the kernel renames a directory by creating the new tree and moving each file into it (copying a file only if it can't move, e.g. while it's open elsewhere), then removing the old tree. Within the kernel the rename is atomic to readers: a lookup or listing under the old or the new name, or of either parent, waits until the whole tree has moved. So a stage-then-rename updater (pnpm's, an atomic config write) is never seen half there. Another tab sharing the root reads OPFS directly and can see the tree mid-move. A root whose directories can move (the Node memory root) renames them in one step. **If the page reloads or crashes mid-rename**, some files are already in the new tree and the rest are still in the old one. So before the first move, the kernel writes the rename's intent (`{ from, to }`) to its metadata store, and clears it after the last. At the next boot, when no other tab shares the root (the same rule as for the boot's other repair of stale metadata), the kernel finishes every recorded rename: it moves what's left into the new tree, removes the old name, and clears the record. The moved directory and its files keep their owner, mode and inode, as after a rename that wasn't stopped. While another tab shares the root, a kernel finishes only renames whose kernel is gone ([#1141](https://github.com/ai-ecoverse/slicc-kernel/issues/1141)): the renaming kernel holds a Web Lock (`slicc-rename:<id>`, named in the record) for the whole rename, and the browser releases it when that tab closes or its worker ends. A kernel that boots beside a peer, and every kernel that sees a peer go, finishes each recorded rename whose lock is free, taking that lock while it does, so two survivors never replay the same rename and a live kernel's rename is never touched. A record without a lock (written by an older kernel, or where Web Locks are missing) waits for a boot that's alone, as before. While another tab finishes a rename, this kernel's readers wait as for its own; the other tabs can see the tree mid-move, as during any rename.
713
713
 
714
714
  A lookup follows at most 40 symlinks, as Linux does: the 41st is `ELOOP`, for WASI programs, JS programs and the kernel API alike, with or without `..` in a link's target. Emscripten programs resolve paths in their glue, which stops one link earlier, at 40.
715
715
 
@@ -846,6 +846,24 @@ Programs mount and unmount with `mount(2)` and `umount2(2)`, so a `mount`/`umoun
846
846
  - **Errors** are Linux's: `ENODEV` for an unknown type (or `hostfs` without a hook), `ENOENT` and `ENOTDIR` for the target, `EBUSY` for a target that is mounted already, and `EINVAL` for unmounting what is not a mount point.
847
847
  - **`/proc/mounts`** escapes spaces and backslashes as Linux does, and adds `nomedium` or `failed` to the options of a mount in that state.
848
848
 
849
+ ### The working directory
850
+
851
+ A process's working directory is the directory it changed into, not just its path ([#1164](https://github.com/ai-ecoverse/slicc-kernel/issues/1164)). The kernel records the directory's inode when a process starts and at every `chdir`. A forked child shares its parent's, and so does a spawned child unless the spawn changes directory.
852
+ - **When the directory is replaced:** if it's removed, or something else takes its name (`rm -r d; mkdir d`, `rm -r d; mv n d`, or `mv d x; mv n d`, from this kernel or from another tab on the same root), the process keeps the removed directory:
853
+ - a wasip1 program's `.` (its `.` preopen) can't be opened or listed (`ENOENT`, an empty listing for its preopen descriptor);
854
+ - its relative lookups and creates are `ENOENT`;
855
+ - `..` still reaches the parent.
856
+ - **Detection costs nothing in the steady state:**
857
+ - each held directory has a counter in memory the kernel shares with the process. It moves only when this kernel, another tab, or a `postFsNotice` reports a change at that directory or above it: the kernel's own watcher paths, and every path and tree of a peer change or notice.
858
+ - a process checks the counter, a memory read, before each relative lookup. Only after it moved does it ask the kernel once (`cwd-check`), and the kernel compares the inode at the path with the one it recorded.
859
+ - **The `rm` record:** so that `rm -r d; mkdir d` can be told apart, removing a directory leaves the same small record a rename leaves at the name it vacates (see [Metadata](#metadata)).
860
+ - **Limits:**
861
+ - a WASIX program's C library keeps the working directory as a string and turns relative paths into absolute ones before they reach the kernel. So a WASIX program still sees the new directory through relative paths, and `getcwd` still returns the path, until wasix-sysroot resolves relative paths against the working directory's descriptor ([#1164](https://github.com/ai-ecoverse/slicc-kernel/issues/1164) stays open for that).
862
+ - Emscripten and JS programs: [#1164](https://github.com/ai-ecoverse/slicc-kernel/issues/1164), next.
863
+ - a removal between the check and the lookup is a race, as with any path, and the lookup sees the new directory.
864
+ - on a `hostfs` folder the inode is the host's, which a host file system may reuse for the new directory (ext4 can; APFS doesn't). There the replacement isn't noticed.
865
+ - with more than 1023 distinct working directories held at once, the rest are not tracked.
866
+
849
867
  ### Exec
850
868
 
851
869
  An exec'd program takes over the pid of the process it replaces, as on Linux: `getpid()` and `$$` in the new image, `/proc`, `ps`, `kill` and the parent's `waitpid` all agree on that pid, and the image's parent pid is the replaced process's. Its own children see that pid as their parent. A file removed or renamed over while programs hold it open keeps its bytes for them, as on Linux, whether the descriptors live in the kernel or a program opened the file in its own worker: every open, read-only ones included, is known to the kernel, and all holders share the one file (writes through one are seen through the others). The kernel does not copy it: as NFS does with its "silly rename", it moves the file into a hidden directory, `.slicc-unlinked/`, at the root of the mount it lives on (or of the root file system), and serves the holders' reads and writes from there (a worker's I/O on the old path while the path is gone). No file data is held in memory for it. The file is deleted when the last holder closes it or exits, and the directory goes as soon as it is empty. A file created at the path afterwards is the new file for everyone, also for the holder; a holder whose file another process renames (rather than removes) still uses the old path. A rename over a held file stays atomic for everyone else, as `rename(2)` is: moving the held file aside and the new one into its place are two steps, so a lookup of that name (`open`, `stat`, a read) that comes while they run waits for both, and one that started before them and missed the name in between looks again (also when it spelled the name through a symlinked parent, which the wait doesn't match by path: a miss is retried whenever any replacement finished meanwhile, up to three tries). Every open finds the old file or the new one, never no file (#903). A read through a held descriptor and the rename take the file's turn in order: lookups start to wait for the rename only once it has its turn on every held file it moves aside, so a read that came first finishes, and a rename over a file other processes keep reading never stalls them or itself (#980). A process that opens the file while the rename waits for its turn is a holder too: the rename looks for holders again once it has its turn, so that process keeps the old file as well, and a holder that closed meanwhile leaves nothing to move aside. The same holds for a directory that is removed or renamed over while programs hold files in it: the removal keeps looking for holders until nothing new appears, moving each held file aside, and lookups inside the directory wait for it. An open, a hand-over of a program's descriptor to the kernel (at a fork) or a hold that was under way while the directory went looks its path up again, so it gets the kept old file or ENOENT, never an empty file at the old name (#1013). An open of a path that is gone, with a handle the process no longer holds, is ENOENT rather than a descriptor for a missing file. The OPFS root does the same for its own two steps: a rename onto an existing name replaces the file with `move()` (or, in a browser without it, a copy into the name and a removal of the source), and a lookup that loses that race to a stale handle looks again. A `tmpfs` mount replaces the name in one step; on `fsa` and `hostfs` mounts it is the real file system's `rename`. Listings in the kernel leave `.slicc-unlinked` out, but on an `fsa` or `hostfs` mount it is visible on the real machine while a removed file is held (add `.slicc-unlinked/` to a `.gitignore` there). Each kernel names its entries after itself and holds a Web Lock for that name while it runs; when a mount is set up (and the root at start), the kernel removes the entries whose owner holds no lock, so a tab that died while holding a file leaves nothing behind for long, and entries of other live tabs on the same origin are left alone. Under the Node entry, which has no Web Locks, entries are named after the kernel's process id instead, and an entry is removed when that process is gone (`kill(pid, 0)` fails with `ESRCH`); a reused pid leaves the entry alone. Signals a program ignores stay ignored in the image it execs and in the children it spawns or forks, as POSIX has it, so `nohup cmd` and `trap '' TERM; exec cmd` keep `cmd` safe from them; caught signals go back to their default. The kernel holds what was handed down, so a new image that sets one of those signals back to the default with `signal(SIG_DFL)` still ignores it, until it installs a handler.
@@ -1087,7 +1105,7 @@ OPFS stores names, bytes, sizes and modification times, nothing else. Everything
1087
1105
 
1088
1106
  - mode bits (`chmod`; a `chmod` to the mode a file or directory already has writes nothing and leaves its ctime as it was, which saves a sidecar write for every installer that sets the mode a file was made with);
1089
1107
  - access and change times, and a modification time set explicitly with `utime` (it holds until the file is written again: any write or truncate moves it to the time of the write, also in Chrome, whose OPFS doesn't move a file's `lastModified` for writes through a sync access handle, so the kernel records the write's time itself, and it reaches the metadata store up to 100 ms after the write, so a tab closed inside that window keeps the old mtime on disk; the time follows a rename and is dropped with the file); a directory's times move whenever an entry is created, removed or renamed in it, as POSIX asks, and a time set explicitly after such a change keeps winning, also when the change was made in another kernel and its write lands later; an Emscripten program reads a directory's times again after any call that can wait on another process (a pipe or socket read, `wait`, `poll`, a sleep), so a program that waits for a child or a peer to fill a directory sees the new times (rsync's final directory times rely on it), while a walk without waits reads each directory once; `chown`/`fchown` leave the times alone (Emscripten stamps the setattr of a chown with the current time, which isn't taken as an mtime);
1090
- - inode numbers that stay stable across renames. A file with no record has an inode derived from its path, and a rename keeps it on the file; the rename leaves a small record at the name it vacated with a fresh inode, so a file, symlink or directory made there later doesn't share the renamed one's ([#954](https://github.com/ai-ecoverse/slicc-kernel/issues/954)). An exclusive create doesn't count that record as a taken name, and the boot reconcile keeps it only while a live record still carries the inode it guards;
1108
+ - inode numbers that stay stable across renames. A file with no record has an inode derived from its path, and a rename keeps it on the file; the rename leaves a small record at the name it vacated with a fresh inode, so a file, symlink or directory made there later doesn't share the renamed one's ([#954](https://github.com/ai-ecoverse/slicc-kernel/issues/954)). Removing a directory leaves the same record, so a directory made at its name has a new inode, which is how a process's [working directory](#the-working-directory) notices the swap. The record becomes the new entry's own when something is created there; one with nothing at its name is pruned by the next boot that's alone on the root. An exclusive create doesn't count that record as a taken name, and the boot reconcile keeps it only while a live record still carries the inode it guards;
1091
1109
  - symbolic links (`symlink`, `readlink`, `lstat`, and following them in paths), which exist only in the sidecar, with their own times: `touch -h`, `lutimes` and `utimensat` with `AT_SYMLINK_NOFOLLOW` set the link's, never the target's. A link's own mode can't be changed, as on Linux: `chmod -h`, `lchmod` and `fchmodat` with `AT_SYMLINK_NOFOLLOW` fail with `EOPNOTSUPP` (which `chmod -h` and `tar` ignore for links) and never follow it, and `open` with `O_PATH | O_NOFOLLOW` gives a descriptor for the link itself.
1092
1110
 
1093
1111
  OPFS stays authoritative: an entry is consulted only for a path that exists in OPFS (symlinks only for paths that don't), so files written straight to OPFS, for example by the BIOS installer or a page, get the defaults: directories `0755`, files `0644`, and files under `node_modules/*/bin/` `0755`. A file deleted through the OPFS API disappears at once; its leftover entry is ignored and removed when the next kernel starts. Changes go to OPFS first and then to the sidecar, so a crash in between at worst loses the metadata of that one change. Each change is a read-modify-write inside one IndexedDB transaction, so two kernels (two tabs) on the same origin don't lose each other's updates.
@@ -1235,6 +1253,12 @@ Not part of the run yet: the rest of bios's setup (the `rest` shard, the upgrade
1235
1253
 
1236
1254
  The WASIX fixtures of `test/integration/epoll.test.mjs` (#674), under `test/integration/fixtures/`, are committed with their sources, build scripts and built wasm.
1237
1255
 
1256
+ ### `heldcwd`
1257
+
1258
+ `test/integration/fixtures/heldcwd`: a Rust wasm32-wasip1 program, built by `build.sh` with rustup's wasm32-wasip1 target, for the [working directory](#the-working-directory) rows (#1164).
1259
+ - `heldcwd`: lists `.`, waits for a line on stdin, then lists `.` again, tries a relative create and stat, and lists `..`.
1260
+ - `heldcwd loop N`: stats `old` N times, reads a line, then stats it N more times, for the count of `cwd-check` calls.
1261
+
1238
1262
  ### `tokiotest`
1239
1263
 
1240
1264
  A small tokio program built for WASIX (`wasm32-wasmer-wasi`), the fixture for `test/integration/epoll.test.mjs` (#674). It runs on tokio's current-thread runtime, or on the multi-thread one (2 workers) with `--multi` first:
@@ -900,6 +900,7 @@ var RemoteWatch = class {
900
900
  settled = false;
901
901
  quiet;
902
902
  crowd;
903
+ gone;
903
904
  told;
904
905
  counted = 0;
905
906
  recount() {
@@ -989,6 +990,7 @@ var RemoteWatch = class {
989
990
  this.peers.delete(peer);
990
991
  this.rebuild();
991
992
  this.recount();
993
+ this.gone?.();
992
994
  for (const wait2 of this.waiting.values()) {
993
995
  if (wait2.left.delete(peer) && wait2.left.size === 0) wait2.done();
994
996
  }
@@ -1024,14 +1026,16 @@ var RemoteWatch = class {
1024
1026
  async prune() {
1025
1027
  if (!this.locks) return;
1026
1028
  const held = new Set(((await this.locks.query()).held ?? []).map((lock) => lock.name));
1029
+ let left = false;
1027
1030
  for (const tab of /* @__PURE__ */ new Set([...this.others.keys(), ...this.peers])) {
1028
1031
  if (!held.has(this.lockOf(tab))) {
1032
+ if (this.peers.delete(tab)) left = true;
1029
1033
  this.others.delete(tab);
1030
- this.peers.delete(tab);
1031
1034
  }
1032
1035
  }
1033
1036
  this.rebuild();
1034
1037
  this.recount();
1038
+ if (left) this.gone?.();
1035
1039
  }
1036
1040
  watched() {
1037
1041
  return this.union.size > 0;
@@ -1121,6 +1125,7 @@ var REPLACED_ATTEMPTS = 4;
1121
1125
  var RECONCILE_LISTINGS = 8;
1122
1126
  var EXECUTABLE = /\/node_modules\/(?:@[^/]+\/)?[^/]+\/bin\//;
1123
1127
  var RENAMES = "/.slicc-renames";
1128
+ var RENAME_LOCK = "slicc-rename:";
1124
1129
  function movable(handle) {
1125
1130
  return typeof handle.move === "function";
1126
1131
  }
@@ -1287,8 +1292,11 @@ var OpfsFs = class {
1287
1292
  this.lockPrefix = `slicc-excl:${channel ?? ""}:`;
1288
1293
  this.rootName = channel;
1289
1294
  this.channel = channel === void 0 ? void 0 : new BroadcastChannel(channel);
1290
- this.remote = channel === void 0 ? void 0 : new RemoteWatch(`${channel}:watch`);
1291
- if (this.remote) this.remote.quiet = () => this.quiet().then(() => this.settle?.());
1295
+ this.remote = channel === void 0 ? void 0 : new RemoteWatch(`${channel}:watch`, { locks: this.locks ?? null });
1296
+ if (this.remote) {
1297
+ this.remote.quiet = () => this.quiet().then(() => this.settle?.());
1298
+ this.remote.gone = () => void this.orphans();
1299
+ }
1292
1300
  if (this.channel) {
1293
1301
  const remote = this.remote;
1294
1302
  meta.listen?.((c) => {
@@ -2299,6 +2307,9 @@ var OpfsFs = class {
2299
2307
  }
2300
2308
  }
2301
2309
  await this.meta.remove([found.path]);
2310
+ if (found.handle?.kind === "directory") {
2311
+ await this.meta.update(found.path, (entry) => entry ?? vacatedAt(found.path));
2312
+ }
2302
2313
  this.touch(Date.now(), parentOf3(found.path));
2303
2314
  }
2304
2315
  links() {
@@ -2361,14 +2372,17 @@ var OpfsFs = class {
2361
2372
  const dir = parent.handle;
2362
2373
  const name2 = baseOf(destination);
2363
2374
  const swap = targetStat?.isDirectory === true;
2375
+ let lock;
2364
2376
  const staging = swap ? `.${name2}.${crypto.randomUUID()}` : name2;
2365
2377
  const copying = sourceStat.isDirectory && !movable(source.handle);
2366
2378
  const staged = resolvePath(parent.path, staging);
2367
2379
  const place = async () => {
2368
2380
  this.forget(destination);
2369
2381
  const timed2 = await this.explicitTimes(source.path);
2370
- const first = copying ? await this.intend({ from: source.path, to: staged }) : void 0;
2371
- const second = copying && swap ? await this.intend({ from: staged, to: destination, replace: true }) : void 0;
2382
+ const root = { ino: inodeOf(source.path), ...moving };
2383
+ const owned4 = lock ? { lock } : {};
2384
+ const first = copying ? await this.intend({ from: source.path, to: staged, root, ...owned4 }) : void 0;
2385
+ const second = copying && swap ? await this.intend({ from: staged, to: destination, replace: true, root, ...owned4 }) : void 0;
2372
2386
  const moved2 = await this.replacingTarget(
2373
2387
  targetStat !== null,
2374
2388
  () => this.place(source.handle, dir, staging)
@@ -2377,7 +2391,7 @@ var OpfsFs = class {
2377
2391
  throw err;
2378
2392
  });
2379
2393
  if (swap) await this.rm(destination, { recursive: true });
2380
- if (second) await this.intend({ from: staged, to: destination }, second);
2394
+ if (second) await this.intend({ from: staged, to: destination, root, ...owned4 }, second);
2381
2395
  await this.meta.move(source.path, destination, carry, left);
2382
2396
  this.movePending(source.path, destination);
2383
2397
  if (!moved2) {
@@ -2402,7 +2416,11 @@ var OpfsFs = class {
2402
2416
  const entry = { from: source.path, to: destination, done: promise };
2403
2417
  this.renamingDirs.add(entry);
2404
2418
  try {
2405
- await place();
2419
+ const locks = this.locks;
2420
+ if (locks) {
2421
+ lock = `${RENAME_LOCK}${crypto.randomUUID()}`;
2422
+ await locks.request(lock, place);
2423
+ } else await place();
2406
2424
  } finally {
2407
2425
  this.renamingDirs.delete(entry);
2408
2426
  resolve2();
@@ -2434,12 +2452,40 @@ var OpfsFs = class {
2434
2452
  } catch {
2435
2453
  }
2436
2454
  }
2437
- async finishRenames() {
2438
- const intents = (await this.meta.under(RENAMES)).filter((entry) => entry.rename !== void 0).sort(
2455
+ replaying = Promise.resolve();
2456
+ orphans() {
2457
+ this.replaying = this.replaying.then(() => this.finishRenames(true)).catch(() => 0);
2458
+ return this.replaying;
2459
+ }
2460
+ async finishRenames(orphaned = false) {
2461
+ const locks = this.locks;
2462
+ const held = /* @__PURE__ */ new Set();
2463
+ if (orphaned && locks) {
2464
+ const { held: h = [], pending = [] } = await locks.query();
2465
+ for (const lock of [...h, ...pending]) held.add(lock.name);
2466
+ }
2467
+ const intents = (await this.meta.under(RENAMES)).filter(
2468
+ (entry) => entry.rename !== void 0 && (!orphaned || locks !== void 0 && entry.rename.lock !== void 0 && !held.has(entry.rename.lock))
2469
+ ).sort(
2439
2470
  (a, b) => a.rename.at - b.rename.at
2440
2471
  );
2441
2472
  for (const entry of intents) {
2442
- const { from, to, replace } = entry.rename;
2473
+ const lock = entry.rename.lock;
2474
+ if (!orphaned || !locks || !lock) await this.finishRename(entry);
2475
+ else await locks.request(lock, () => this.finishRename(entry));
2476
+ }
2477
+ return intents.length;
2478
+ }
2479
+ async finishRename(entry) {
2480
+ const [current] = await this.meta.under(entry.path);
2481
+ if (current?.rename === void 0) return;
2482
+ const { from, to, replace, root } = current.rename;
2483
+ const { promise, resolve: resolve2 } = Promise.withResolvers();
2484
+ const barrier = { from, to, done: promise };
2485
+ this.renamingDirs.add(barrier);
2486
+ this.forget(from);
2487
+ this.forget(to);
2488
+ try {
2443
2489
  const left = await this.real(from);
2444
2490
  if (left && replace && await this.real(to)) await this.rm(to, { recursive: true });
2445
2491
  if (left) {
@@ -2456,11 +2502,23 @@ var OpfsFs = class {
2456
2502
  this.forget(to);
2457
2503
  }
2458
2504
  if ((await this.meta.under(from)).length > 0) {
2459
- await this.meta.move(from, to, (moved2) => ({ ...occupied(moved2), path: to }));
2505
+ await this.meta.move(
2506
+ from,
2507
+ to,
2508
+ (moved2) => ({
2509
+ ...occupied(moved2),
2510
+ ...root,
2511
+ ino: occupied(moved2).ino ?? root?.ino ?? inodeOf(from),
2512
+ path: to
2513
+ }),
2514
+ void 0
2515
+ );
2460
2516
  }
2461
2517
  await this.meta.update(entry.path, () => void 0);
2518
+ } finally {
2519
+ this.renamingDirs.delete(barrier);
2520
+ resolve2();
2462
2521
  }
2463
- return intents.length;
2464
2522
  }
2465
2523
  async settledRename(path) {
2466
2524
  if (this.renamingDirs.size === 0 && this.copying.size === 0 || typeof path !== "string")
@@ -2744,7 +2802,10 @@ var OpfsFs = class {
2744
2802
  };
2745
2803
  }
2746
2804
  async reconcile() {
2747
- if (this.remote && await this.remote.peered()) return 0;
2805
+ if (this.remote && await this.remote.peered()) {
2806
+ await this.orphans();
2807
+ return 0;
2808
+ }
2748
2809
  await this.finishRenames();
2749
2810
  let removed = 0;
2750
2811
  const entries = await this.meta.all();
@@ -7034,7 +7095,7 @@ function satisfies(version, range2) {
7034
7095
  }
7035
7096
 
7036
7097
  // src/version.ts
7037
- var KERNEL_VERSION = true ? "1.133.3" : "0.0.0-dev";
7098
+ var KERNEL_VERSION = true ? "1.133.5" : "0.0.0-dev";
7038
7099
  var OS_RELEASE = "6.0.0-slicc";
7039
7100
  function procVersion() {
7040
7101
  return `Linux version ${OS_RELEASE} (slicc-kernel ${KERNEL_VERSION}) #1 SMP PREEMPT_DYNAMIC
@@ -10727,6 +10788,88 @@ var FifoTable = class {
10727
10788
  }
10728
10789
  };
10729
10790
 
10791
+ // src/kernel/held-cwd.ts
10792
+ var CWD_SLOTS = 1024;
10793
+ var UNTRACKED = CWD_SLOTS - 1;
10794
+ var under4 = (path, changed) => changed === "/" || path === changed || path.startsWith(`${changed}/`);
10795
+ var CwdTable = class {
10796
+ sab = new SharedArrayBuffer(CWD_SLOTS * 4);
10797
+ view = new Int32Array(this.sab);
10798
+ held = /* @__PURE__ */ new Map();
10799
+ free = [];
10800
+ next = 0;
10801
+ checks = 0;
10802
+ inodeOf;
10803
+ constructor(inodeOf2) {
10804
+ this.inodeOf = inodeOf2;
10805
+ }
10806
+ async hold(path, from) {
10807
+ const shared = from === void 0 ? void 0 : this.held.get(from);
10808
+ if (shared && shared.path === path) return this.share(from);
10809
+ const ino = await this.inodeOf(path).catch(() => void 0);
10810
+ const slot2 = this.free.pop() ?? (this.next < UNTRACKED ? this.next++ : UNTRACKED);
10811
+ if (slot2 === UNTRACKED) return slot2;
10812
+ this.held.set(slot2, {
10813
+ path,
10814
+ ino,
10815
+ refs: 1,
10816
+ dead: false,
10817
+ verified: Atomics.load(this.view, slot2)
10818
+ });
10819
+ return slot2;
10820
+ }
10821
+ share(slot2) {
10822
+ const held = slot2 === void 0 ? void 0 : this.held.get(slot2);
10823
+ if (!held) return UNTRACKED;
10824
+ held.refs++;
10825
+ return slot2;
10826
+ }
10827
+ release(slot2) {
10828
+ const held = slot2 === void 0 ? void 0 : this.held.get(slot2);
10829
+ if (!held || --held.refs > 0) return;
10830
+ this.held.delete(slot2);
10831
+ Atomics.add(this.view, slot2, 1);
10832
+ this.free.push(slot2);
10833
+ }
10834
+ stateOf(slot2) {
10835
+ const held = this.held.get(slot2);
10836
+ return { seen: held?.verified ?? Atomics.load(this.view, slot2), dead: held?.dead === true };
10837
+ }
10838
+ touched(paths, trees = []) {
10839
+ if (this.held.size === 0) return;
10840
+ for (const [slot2, held] of this.held) {
10841
+ if (held.dead) continue;
10842
+ if (paths.some((p) => under4(held.path, p)) || trees.some((t) => under4(held.path, t))) {
10843
+ Atomics.add(this.view, slot2, 1);
10844
+ }
10845
+ }
10846
+ }
10847
+ async check(slot2) {
10848
+ this.checks++;
10849
+ const gen = Atomics.load(this.view, slot2);
10850
+ const held = this.held.get(slot2);
10851
+ if (!held) return { gen, alive: true };
10852
+ if (!held.dead) {
10853
+ const ino = await this.inodeOf(held.path).catch(() => null);
10854
+ held.dead = ino === null || held.ino !== void 0 && ino !== held.ino;
10855
+ held.verified = gen;
10856
+ }
10857
+ return { gen, alive: !held.dead };
10858
+ }
10859
+ };
10860
+ function heldInit(o) {
10861
+ if (!o.cwds || o.cwdSlot === void 0) return {};
10862
+ const state = o.cwdState ?? { seen: 0, dead: false };
10863
+ return { held: { sab: o.cwds, slot: o.cwdSlot, ...state } };
10864
+ }
10865
+ function heldHooks(o) {
10866
+ if (!o.cwdCheck || !o.cwdChdir) return {};
10867
+ return { cwdCheck: o.cwdCheck, cwdChdir: o.cwdChdir };
10868
+ }
10869
+ function inheritedCwd(chdir, parent) {
10870
+ return chdir ? void 0 : parent;
10871
+ }
10872
+
10730
10873
  // src/fs/copy.ts
10731
10874
  async function present2(fs, path) {
10732
10875
  return fs.lstat(path).then(
@@ -21084,6 +21227,8 @@ var SYSCALL_OPS = /* @__PURE__ */ new Set([
21084
21227
  "fd-list",
21085
21228
  "proc-alarm",
21086
21229
  "proc-umask",
21230
+ "cwd-check",
21231
+ "cwd-chdir",
21087
21232
  "proc-cred",
21088
21233
  "proc-setcred",
21089
21234
  "dl-log",
@@ -22409,6 +22554,14 @@ var WasmProcess = class {
22409
22554
  return { ok: true, kind: "void" };
22410
22555
  case "proc-umask":
22411
22556
  return { ok: true, kind: "json", json: this.setUmask(req.mask) };
22557
+ case "cwd-check":
22558
+ return {
22559
+ ok: true,
22560
+ kind: "json",
22561
+ json: await this.options.cwdCheck?.() ?? { gen: 0, alive: true }
22562
+ };
22563
+ case "cwd-chdir":
22564
+ return { ok: true, kind: "json", json: await this.options.cwdChdir?.(req.path) ?? {} };
22412
22565
  case "proc-cred":
22413
22566
  return { ok: true, kind: "json", json: copyCred(this.cred) };
22414
22567
  case "proc-setcred":
@@ -22958,6 +23111,7 @@ function processInit(opts, sab, umask, cred, lane) {
22958
23111
  umask,
22959
23112
  cred,
22960
23113
  ...opts.changes ? { changes: opts.changes } : {},
23114
+ ...heldInit(opts),
22961
23115
  ...opts.owners ? { owners: true } : {},
22962
23116
  ...opts.links !== void 0 ? { links: true } : {},
22963
23117
  ...opts.ppid !== void 0 ? { ppid: opts.ppid } : {}
@@ -23031,6 +23185,7 @@ function processOptions(opts, header3, hooks, created4) {
23031
23185
  ...opts.identity ? { identity: opts.identity } : {},
23032
23186
  ...opts.onSyscall ? { onSyscall: opts.onSyscall } : {},
23033
23187
  ...opts.settling ? { settling: opts.settling } : {},
23188
+ ...heldHooks(opts),
23034
23189
  spawner: opts.spawner,
23035
23190
  forker: opts.forker,
23036
23191
  ...opts.fork?.dl ? { dl: opts.fork.dl } : {},
@@ -23081,6 +23236,7 @@ function threadInit(opts, program, sab, cred, thread, main) {
23081
23236
  owners: opts.owners === true,
23082
23237
  ...opts.links !== void 0 ? { links: true } : {},
23083
23238
  ...opts.changes ? { changes: opts.changes } : {},
23239
+ ...heldInit(opts),
23084
23240
  ...opts.ppid !== void 0 ? { ppid: opts.ppid } : {},
23085
23241
  thread,
23086
23242
  signals: main
@@ -26712,6 +26868,11 @@ var Launcher = class {
26712
26868
  hostname;
26713
26869
  watchers = new FsWatchers();
26714
26870
  changes = new FileChanges();
26871
+ cwds = new CwdTable(async (path) => {
26872
+ const { dev, ino } = await this.base.stat(path);
26873
+ return `${dev ?? 0}:${ino}`;
26874
+ });
26875
+ cwdSlots = /* @__PURE__ */ new Map();
26715
26876
  pnpmHome;
26716
26877
  pnpmChain;
26717
26878
  pnpmMade;
@@ -26768,7 +26929,10 @@ var Launcher = class {
26768
26929
  this.sealed.tickets.normalise = (path) => physicalPathAsync(this.base, path);
26769
26930
  this.sealed.userOf = async (uid) => (await this.users.lookup(uid))?.name;
26770
26931
  if (this.owners) options.fs.trackOwners?.();
26771
- this.watchers.tap((paths) => this.changes.publish(...paths));
26932
+ this.watchers.tap((paths) => {
26933
+ this.changes.publish(...paths);
26934
+ this.cwds.touched(paths);
26935
+ });
26772
26936
  this.watchers.onReshape(() => {
26773
26937
  for (const nodes of this.openFiles) nodes.reshaped();
26774
26938
  });
@@ -26838,6 +27002,7 @@ var Launcher = class {
26838
27002
  options.fs.onPeer?.(() => this.flushOpen());
26839
27003
  options.fs.remoteChanges?.((change) => {
26840
27004
  this.changes.flush();
27005
+ this.cwds.touched(change.paths, change.trees);
26841
27006
  this.rewritten(change.paths, change.trees);
26842
27007
  });
26843
27008
  const holders = [this.held, this.openFiles];
@@ -27620,11 +27785,12 @@ var Launcher = class {
27620
27785
  }
27621
27786
  const done5 = this.heldUrl(program);
27622
27787
  try {
27788
+ const cwdSlot = await this.cwds.hold(cwd, req.cwdFrom);
27623
27789
  const handle = await this.admitted(
27624
27790
  program.abi !== "js",
27625
27791
  req.fds,
27626
- (fds, quietOom) => this.start({ ...req, fds, quietOom, cwd, program, argv0: target.argv0, args, env })
27627
- );
27792
+ (fds, quietOom) => this.start({ ...req, fds, quietOom, cwd, program, argv0: target.argv0, args, env, cwdSlot })
27793
+ ).finally(() => this.cwds.release(cwdSlot));
27628
27794
  void handle.exited.finally(done5);
27629
27795
  return handle;
27630
27796
  } catch (err) {
@@ -27711,6 +27877,8 @@ var Launcher = class {
27711
27877
  throw new SpawnError("ESRCH");
27712
27878
  }
27713
27879
  const pid = req.pid ?? this.nextPid++;
27880
+ const cwdSlot = this.cwds.share(req.cwdSlot);
27881
+ this.cwdSlots.set(pid, cwdSlot);
27714
27882
  const scrubs = this.treeScrubs(req);
27715
27883
  const stack = this.stackFor(req.tag);
27716
27884
  const terminal = req.fds.stdioTerminal();
@@ -27745,6 +27913,7 @@ var Launcher = class {
27745
27913
  ...this.dac ? { dac: this.dac } : {},
27746
27914
  program: req.program,
27747
27915
  changes: this.changes.sab,
27916
+ ...this.heldFor(pid, cwdSlot),
27748
27917
  argv0: req.argv0,
27749
27918
  args: req.args,
27750
27919
  env: req.env,
@@ -27819,6 +27988,8 @@ var Launcher = class {
27819
27988
  (state) => state === "stopped" ? this.stopped.add(pid) : this.stopped.delete(pid)
27820
27989
  );
27821
27990
  void handle.exited.then(() => {
27991
+ this.cwds.release(this.cwdSlots.get(pid));
27992
+ this.cwdSlots.delete(pid);
27822
27993
  this.freshModules.forget(pid);
27823
27994
  const spent = handle.times?.();
27824
27995
  if (spent) this.spent.set(pid, spent);
@@ -28050,6 +28221,7 @@ var Launcher = class {
28050
28221
  const handle = await this.launch(planned, {
28051
28222
  ...deciding ? { decided: deciding.promise } : {},
28052
28223
  cwd: req.cwd,
28224
+ cwdFrom: inheritedCwd(req.chdir, this.cwdSlots.get(ppid)),
28053
28225
  ...redeemed,
28054
28226
  fds,
28055
28227
  report,
@@ -28111,6 +28283,24 @@ var Launcher = class {
28111
28283
  for (const resolve2 of waiting) resolve2(true);
28112
28284
  }, 0);
28113
28285
  }
28286
+ heldFor(pid, slot2) {
28287
+ return {
28288
+ cwds: this.cwds.sab,
28289
+ cwdSlot: slot2,
28290
+ cwdState: this.cwds.stateOf(slot2),
28291
+ cwdCheck: async () => {
28292
+ const current = this.cwdSlots.get(pid);
28293
+ return { ...await this.cwds.check(current), slot: current };
28294
+ },
28295
+ cwdChdir: (path) => this.chdirHeld(pid, path)
28296
+ };
28297
+ }
28298
+ async chdirHeld(pid, path) {
28299
+ this.cwds.release(this.cwdSlots.get(pid));
28300
+ const slot2 = await this.cwds.hold(path);
28301
+ this.cwdSlots.set(pid, slot2);
28302
+ return { slot: slot2, ...this.cwds.stateOf(slot2) };
28303
+ }
28114
28304
  forker(ppid, parent) {
28115
28305
  const { exec: _exec, pgid: _pgid, group: _group, pid: _pid, scrub: _scrub, ...image } = parent;
28116
28306
  return async (state, given) => {
@@ -28118,6 +28308,7 @@ var Launcher = class {
28118
28308
  ...image,
28119
28309
  quietOom,
28120
28310
  cwd: state.cwd ?? parent.cwd,
28311
+ cwdSlot: this.cwdSlots.get(ppid),
28121
28312
  fds,
28122
28313
  ppid,
28123
28314
  fork: state,