@ai-ecoverse/slicc-kernel 1.116.1 → 1.117.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
@@ -589,6 +589,7 @@ Directories are renamed with `FileSystemHandle.move()` where available, else by
589
589
  - **The node's identity, not its name, picks the FIFO:** an open FIFO keeps working after an `unlink` or a `rename`, and a new `mkfifo` at the same name is another FIFO. `/proc/<pid>/fd/N` names the FIFO's path, also in children that inherit the end.
590
590
  - **One kernel per storage root:** the table of open FIFOs is per kernel, so two tabs (two kernels on one origin) opening the same FIFO don't connect, as with record locks ([#675](https://github.com/ai-ecoverse/slicc-kernel/issues/675)).
591
591
  - GNU make 4.4's `-jN` uses a FIFO jobserver this way (`--jobserver-auth=fifo:PATH`), so sub-makes join it by path.
592
+ - **Below an open** ([#908](https://github.com/ai-ecoverse/slicc-kernel/issues/908)): a FIFO or socket node is never read or written as a plain file. A JS program's `ctx.fs.readFile`/`writeFile` and the kernel API's `readFile`/`writeFile` (and the positioned reads, writes and truncates under them) fail with `ENXIO` on either, because they read or write a whole file rather than open one. Emscripten and WASI programs' opens of a FIFO go through the kernel's FIFO open as above, and a WASI program's open of a socket node is `ENXIO`, as on Linux.
592
593
 
593
594
  `mmap` of a regular file works on every file system, for Emscripten programs built with `mmap` (whose glue exports `emscripten_builtin_memalign`) as well as for WASIX ones: the kernel reads the mapped range into fresh memory of the process, as Emscripten's MEMFS does. A `MAP_PRIVATE` mapping may be written, and its changes stay in the process. A writable `MAP_SHARED` mapping of such a file is `ENODEV`; a file in the process's own in-memory file system (MEMFS, as under `/dev`) keeps Emscripten's own mapping, which writes back. A read-only `MAP_SHARED` mapping is a copy taken at `mmap`, so later changes to the file do not show in it.
594
595
 
@@ -617,6 +618,13 @@ Directories are renamed with `FileSystemHandle.move()` where available, else by
617
618
  - **F_GETLK:** with a conflicting lock, it writes that lock back (`l_whence` 0, its absolute start, its length, the holder's pid). Without one, it sets only `l_type` to `2`.
618
619
  - **Errors (WASI numbers):** `EAGAIN` for a conflict on `F_SETLK` (never `EACCES`), `EINTR` when a signal with a handler ends an `F_SETLKW` wait, `EDEADLK` for a wait that would close a cycle, `EBADF` for a bad descriptor or a lock type the descriptor's access mode doesn't allow, and `EINVAL` for a bad `l_type` or `l_whence` or a start before 0.
619
620
  - **Semantics:** the same as Emscripten's, above. A libc that finds no import on an older kernel gets `ENOSYS` and keeps its local answer.
621
+ - **Any kind of descriptor** ([#893](https://github.com/ai-ecoverse/slicc-kernel/issues/893)): a pipe, a socket or a terminal takes record locks too, as on Linux, keyed by the object (both ends of a pipe are one object, each socket is its own, a terminal is one object however often it's opened). `l_whence` `CUR` and `END` count from 0 there.
622
+ - **`slicc.flock(fd, op) -> errno`** ([#893](https://github.com/ai-ecoverse/slicc-kernel/issues/893)): BSD `flock(2)` locks, with Linux's semantics, for a WASIX program on any descriptor: a regular file, a directory, a pipe, a socket, a terminal or a `/dev` device.
623
+ - **op:** Linux's bits, `LOCK_SH` `1`, `LOCK_EX` `2` or `LOCK_UN` `8`, optionally with `LOCK_NB` `4`. Anything else is `EINVAL`.
624
+ - **Owner:** the open file description, not the process. A `dup`'d descriptor and one a forked child or a spawned program inherits share it, so the child's `LOCK_EX|LOCK_NB` is granted and its `LOCK_UN` releases the lock for the parent too. So children that `flock(STDOUT)` an inherited STDOUT don't exclude each other, on Linux either: for that, each child opens a lock file itself. A second open of the same object is another description and conflicts: the file opened again (by any name of it), `/dev/tty` opened again, the other end of a pipe.
625
+ - **Rules:** shared locks coexist, an exclusive one excludes every other description, and any access mode takes either kind, so `LOCK_EX` on a read-only open excludes (homescoop#758). A conversion drops the old lock before it takes the new one, as on Linux, so a refused `LOCK_NB` conversion leaves no lock. The lock goes with `LOCK_UN` or when the description's last descriptor closes, at exit or kill too. flock locks and `fcntl` record locks are separate and don't block each other.
626
+ - **Errors (WASI numbers):** `EAGAIN` for a conflict with `LOCK_NB`, `EINTR` when a signal with a handler ends a wait (as for `F_SETLKW`: a libc that wants `SA_RESTART` asks again), `EBADF` for a bad descriptor, `EINVAL` for a bad op. There's no deadlock detection, as on Linux.
627
+ - **Limits:** locks are per kernel ([#675](https://github.com/ai-ecoverse/slicc-kernel/issues/675)). A file the program buffers itself is handed to the kernel at its first `flock`, with every other descriptor the program has open on it. A libc that finds no import on an older kernel gets `ENOSYS`; wasix-libc's `flock()` then keeps its old answer. Emscripten programs and the imports module's `tryLock` are unchanged.
620
628
  - **Rules on a mount:**
621
629
  - a rename across mounts is `EXDEV`, so `mv` copies;
622
630
  - a symlink on the root file system that leads onto a mount, such as `~/proj` → `/mnt/drive/proj`, or a chain from a mount to the root and back, is followed onto the mount by every operation, in every runtime: reads, writes, `stat`, listings, `mkdir`, removals, renames, `chmod`, `chown` and times. Removing the link itself removes the link. Before [#547](https://github.com/ai-ecoverse/slicc-kernel/issues/547) was fixed, the kernel resolved such a link against the directory under the mount point for JS and WASI programs, the embedder's `fs` and attached clients (Emscripten programs weren't affected): reads failed with `ENOENT`, and writes went into that hidden directory, which shows again once the mount is gone;
@@ -969,7 +977,7 @@ Releases are cut by semantic-release on every push to `main`, in two jobs, and o
969
977
  Every change is certified in its pull request, before it merges (`cert-review-pr.yml`, next section), and `cert-review` is a required check of the merge queue, so every commit on `main`, and every release built from it, was certified first. A release is then **verified on npm** (`release / verify on npm` in `release.yml`, `tools/cert-review/verify.mjs`), in code, for every tag the run made:
970
978
 
971
979
  - It waits up to an hour for npm to serve the version (an accepted publish can take a while to appear), then downloads the tarball and checks `dist.integrity` (sha512), the SLSA v1 provenance (the tarball's sha512 is its subject; `release.yml` on `refs/heads/main`, a GitHub-hosted builder; `gitCommit` = `gitHead` = the tag, on `main`), and `npm audit signatures`. A version that `release-recover.mjs` skipped because npm's `latest` is newer (#837) has nothing to verify.
972
- - It then writes `certified @ai-ecoverse/slicc-kernel@<v> sha256=<64 hex> (covers <first>–<v>; ci: <run>; pr-certified: <each merged pull request's cert-review>; known: <open known-issue issues>)` into the GitHub release's notes, between `<!-- cert-verify -->` markers. homescoop's ci reads exactly that prefix and re-hashes the npm tarball against it. `covers` starts after the last version npm serves, so a skipped version is covered by the next. Notes that already carry the identical line stay unchanged.
980
+ - It then writes `certified @ai-ecoverse/slicc-kernel@<v> sha256=<64 hex> (covers <first>–<v>; ci: <run>; pr-certified: <each merged pull request's cert-review at its merge>; known: <open known-issue issues>)` into the GitHub release's notes, between `<!-- cert-verify -->` markers. homescoop's ci reads exactly that prefix and re-hashes the npm tarball against it. `covers` starts after the last version npm serves, so a skipped version is covered by the next. Notes that already carry the identical line stay unchanged. A pull request's `cert-review` is the newest finished one (success, failure or error) on its head at or before its merge, or the newest pending one if none had finished. A re-review that a later `merge-ok` re-label starts on the same head, still pending at the merge or ending after it, doesn't change what the merge queue saw, and a status posted after the merge can't change a release's line.
973
981
  - A mismatch writes no line. The job is red with a `supply-chain alert`, and so is a line already in the notes with another sha256.
974
982
 
975
983
  The tools in `tools/cert-review/`:
@@ -1240,6 +1248,16 @@ A WASIX C program for `test/integration/wasix-locks.test.mjs` (#722). It calls `
1240
1248
 
1241
1249
  **Build:** `INSTALL_WASIXCC=<homescoop>/scripts/install-wasixcc.sh WASIX_SYSROOT_28=<unpacked @ai-ecoverse/wasix-sysroot 2025.9.30-28> ./build.sh`, with homescoop's pinned wasixcc 0.4.7 (LLVM 21.1.206, binaryen 133) and Homebrew's binaryen `wasm-opt` 132 on `PATH` for the Asyncify pass (`proc_fork`). Two builds give the same bytes: `bin/wlocktest.wasm` is 56,958 bytes, sha256 `31308a54e13e7dcac497ad9d8a05799008c8beaa4ae3a36c76e218af7235ec23`.
1242
1250
 
1251
+ ### `flocktest`
1252
+
1253
+ A WASIX C program for `test/integration/flock.test.mjs` (#893). It calls `slicc.flock` and `slicc.fcntl_lock` directly (`import_module("slicc")`). Its arguments are a sequence of steps, run in order:
1254
+ - `open SLOT r|w|rw PATH`, `pipe SLOT SLOT`, `pair SLOT SLOT` (a `socketpair`), `dup FROM TO`, `fd SLOT N` (names an inherited descriptor), `close SLOT`;
1255
+ - `flock SLOT OP`, where OP is made of `sh`, `ex`, `un` and `nb` (`exnb` is `LOCK_EX|LOCK_NB`) or is a number; `badfd`; `fcntl SLOT r|w|u` (`F_SETLK` on the whole file);
1256
+ - `fork … end` (the child runs the steps up to the matching `end`, its lines start with `child `, and the parent waits for it before it goes on after `end`);
1257
+ - `touch PATH`, `await PATH`, `sleep MS`, `say TEXT`, `trap` (a `SIGUSR1` handler).
1258
+
1259
+ **Build:** as `wlocktest`. Two builds give the same bytes: `bin/flocktest.wasm` is 58,442 bytes, sha256 `687ecf21c836adda4ef2e328b6032eed28ca7d37efc8e88d3aaefb8fc117115a`.
1260
+
1243
1261
  ## License
1244
1262
 
1245
1263
  Apache-2.0
@@ -1699,13 +1699,20 @@ var OpfsFs = class {
1699
1699
  if (handle.kind === "directory") throw fsError("EISDIR", path);
1700
1700
  return handle;
1701
1701
  }
1702
+ async refuseNode(path, empty) {
1703
+ if (!empty) return;
1704
+ const [entry] = await this.meta.get([(await this.locate(path, true)).path]);
1705
+ if (entry?.fifo || entry?.socket) throw fsError("ENXIO", path);
1706
+ }
1702
1707
  async readFileBuffer(path) {
1703
1708
  const handle = await this.existing(path);
1704
- return this.retrying(
1709
+ const bytes = await this.retrying(
1705
1710
  path,
1706
1711
  async () => new Uint8Array(await (await handle.getFile()).arrayBuffer()),
1707
1712
  false
1708
1713
  );
1714
+ await this.refuseNode(path, bytes.length === 0);
1715
+ return bytes;
1709
1716
  }
1710
1717
  async readVersioned(path, cached = false) {
1711
1718
  const handle = await this.existing(path);
@@ -1715,6 +1722,7 @@ var OpfsFs = class {
1715
1722
  const stamp = await this.stamp(path, cached);
1716
1723
  const file = await handle.getFile();
1717
1724
  const bytes = new Uint8Array(await file.arrayBuffer());
1725
+ await this.refuseNode(path, bytes.length === 0);
1718
1726
  return { bytes, version: `${file.lastModified}:${file.size}:${stamp}` };
1719
1727
  },
1720
1728
  false
@@ -1736,7 +1744,11 @@ var OpfsFs = class {
1736
1744
  const handle = await this.existing(path);
1737
1745
  return this.retrying(
1738
1746
  path,
1739
- async () => new Uint8Array(await (await handle.getFile()).slice(offset, offset + length2).arrayBuffer()),
1747
+ async () => {
1748
+ const file = await handle.getFile();
1749
+ await this.refuseNode(path, file.size === 0);
1750
+ return new Uint8Array(await file.slice(offset, offset + length2).arrayBuffer());
1751
+ },
1740
1752
  false
1741
1753
  );
1742
1754
  }
@@ -1752,6 +1764,7 @@ var OpfsFs = class {
1752
1764
  }
1753
1765
  async truncate(path, size) {
1754
1766
  const handle = await this.existing(path, true);
1767
+ await this.refuseNode(path, (await handle.getFile()).size === 0);
1755
1768
  const resolve2 = this.ranged || this.remote?.watched();
1756
1769
  const target = resolve2 || this.counted.length > 0 ? (await this.locate(path, true)).path : path;
1757
1770
  await this.counting(target);
@@ -1804,6 +1817,10 @@ var OpfsFs = class {
1804
1817
  }
1805
1818
  async fileFor(path) {
1806
1819
  const [target, existed] = await this.writeTarget(path);
1820
+ if (existed) {
1821
+ const [entry] = await this.meta.get([target]);
1822
+ if (entry?.fifo || entry?.socket) throw fsError("ENXIO", path);
1823
+ }
1807
1824
  const [dir, name2] = await this.parent(target);
1808
1825
  try {
1809
1826
  const handle = await this.inDir(
@@ -3780,7 +3797,8 @@ function openPipe(capacity) {
3780
3797
  },
3781
3798
  close: () => pipe.closeRead(),
3782
3799
  poll: () => ({ readable: pipe.readReady, writable: false, hangup: pipe.writersGone }),
3783
- changed: (signal) => pipe.changed(signal)
3800
+ changed: (signal) => pipe.changed(signal),
3801
+ identity: pipe
3784
3802
  }),
3785
3803
  write: new OpenFile({
3786
3804
  write: async (bytes, signal) => {
@@ -3792,7 +3810,8 @@ function openPipe(capacity) {
3792
3810
  },
3793
3811
  close: () => pipe.closeWrite(),
3794
3812
  poll: () => ({ readable: false, writable: pipe.writeReady, hangup: pipe.readersGone }),
3795
- changed: (signal) => pipe.changed(signal)
3813
+ changed: (signal) => pipe.changed(signal),
3814
+ identity: pipe
3796
3815
  })
3797
3816
  };
3798
3817
  }
@@ -6241,7 +6260,7 @@ function satisfies(version, range2) {
6241
6260
  }
6242
6261
 
6243
6262
  // src/version.ts
6244
- var KERNEL_VERSION = true ? "1.116.1" : "0.0.0-dev";
6263
+ var KERNEL_VERSION = true ? "1.117.0" : "0.0.0-dev";
6245
6264
  var OS_RELEASE = "6.0.0-slicc";
6246
6265
  function procVersion() {
6247
6266
  return `Linux version ${OS_RELEASE} (slicc-kernel ${KERNEL_VERSION}) #1 SMP PREEMPT_DYNAMIC
@@ -9607,6 +9626,7 @@ var FifoTable = class {
9607
9626
  let open = true;
9608
9627
  return new OpenFile({
9609
9628
  fifo: path,
9629
+ identity: fifo,
9610
9630
  ...reads ? {
9611
9631
  read: async (max, signal) => {
9612
9632
  try {
@@ -13040,7 +13060,71 @@ function lockRange(start2, len) {
13040
13060
  if (from < 0) throw new KernelError("EINVAL");
13041
13061
  return { start: from, end };
13042
13062
  }
13063
+ function changedAt(wakers, key, signal) {
13064
+ return new Promise((resolve2, reject) => {
13065
+ const set3 = wakers.get(key) ?? /* @__PURE__ */ new Set();
13066
+ wakers.set(key, set3);
13067
+ const done4 = () => {
13068
+ set3.delete(wake);
13069
+ if (set3.size === 0 && wakers.get(key) === set3) wakers.delete(key);
13070
+ signal.removeEventListener("abort", abort);
13071
+ };
13072
+ const wake = () => {
13073
+ done4();
13074
+ resolve2();
13075
+ };
13076
+ const abort = () => {
13077
+ done4();
13078
+ reject(new KernelError("EINTR"));
13079
+ };
13080
+ set3.add(wake);
13081
+ signal.addEventListener("abort", abort, { once: true });
13082
+ });
13083
+ }
13084
+ function wakeAt(wakers, key) {
13085
+ for (const wake of [...wakers.get(key) ?? []]) wake();
13086
+ }
13087
+ var FlockTable = class {
13088
+ held = /* @__PURE__ */ new Map();
13089
+ keys = /* @__PURE__ */ new WeakMap();
13090
+ wakers = /* @__PURE__ */ new Map();
13091
+ free(key, owner, kind) {
13092
+ for (const [other, has] of this.held.get(key) ?? [])
13093
+ if (other !== owner && (kind === "exclusive" || has === "exclusive")) return false;
13094
+ return true;
13095
+ }
13096
+ set(key, owner, kind) {
13097
+ const has = this.held.get(key)?.get(owner);
13098
+ if (has === kind) return true;
13099
+ if (has !== void 0) this.drop(owner);
13100
+ if (kind === "unlock") return true;
13101
+ if (!this.free(key, owner, kind)) return false;
13102
+ const holders = this.held.get(key) ?? /* @__PURE__ */ new Map();
13103
+ holders.set(owner, kind);
13104
+ this.held.set(key, holders);
13105
+ this.keys.set(owner, key);
13106
+ return true;
13107
+ }
13108
+ async wait(key, owner, kind, blocking) {
13109
+ if (this.set(key, owner, kind)) return;
13110
+ const signal = blocking();
13111
+ while (!this.set(key, owner, kind)) {
13112
+ if (signal.aborted) throw new KernelError("EINTR");
13113
+ await changedAt(this.wakers, key, signal);
13114
+ }
13115
+ }
13116
+ drop(owner) {
13117
+ const key = this.keys.get(owner);
13118
+ if (key === void 0) return;
13119
+ this.keys.delete(owner);
13120
+ const holders = this.held.get(key);
13121
+ holders?.delete(owner);
13122
+ if (holders?.size === 0) this.held.delete(key);
13123
+ wakeAt(this.wakers, key);
13124
+ }
13125
+ };
13043
13126
  var LockTable = class {
13127
+ flocks = new FlockTable();
13044
13128
  records = /* @__PURE__ */ new Map();
13045
13129
  blocked = /* @__PURE__ */ new Map();
13046
13130
  wakers = /* @__PURE__ */ new Map();
@@ -13098,28 +13182,10 @@ var LockTable = class {
13098
13182
  return false;
13099
13183
  }
13100
13184
  changed(key, signal) {
13101
- return new Promise((resolve2, reject) => {
13102
- const set3 = this.wakers.get(key) ?? /* @__PURE__ */ new Set();
13103
- this.wakers.set(key, set3);
13104
- const done4 = () => {
13105
- set3.delete(wake);
13106
- if (set3.size === 0 && this.wakers.get(key) === set3) this.wakers.delete(key);
13107
- signal.removeEventListener("abort", abort);
13108
- };
13109
- const wake = () => {
13110
- done4();
13111
- resolve2();
13112
- };
13113
- const abort = () => {
13114
- done4();
13115
- reject(new KernelError("EINTR"));
13116
- };
13117
- set3.add(wake);
13118
- signal.addEventListener("abort", abort, { once: true });
13119
- });
13185
+ return changedAt(this.wakers, key, signal);
13120
13186
  }
13121
13187
  wake(key) {
13122
- for (const wake of [...this.wakers.get(key) ?? []]) wake();
13188
+ wakeAt(this.wakers, key);
13123
13189
  }
13124
13190
  store(key, locks) {
13125
13191
  if (locks.length === 0) this.records.delete(key);
@@ -13184,6 +13250,7 @@ var HOST_OPS = [
13184
13250
  "lk-set",
13185
13251
  "lk-get",
13186
13252
  "lk-close",
13253
+ "flock",
13187
13254
  "async-submit",
13188
13255
  "async-wait",
13189
13256
  "async-take",
@@ -13336,6 +13403,8 @@ async function hostSyscall(req, ctx) {
13336
13403
  case "lk-get":
13337
13404
  case "lk-close":
13338
13405
  return recordLock2(req, ctx);
13406
+ case "flock":
13407
+ return flock(req, ctx);
13339
13408
  case "async-submit":
13340
13409
  if (!ctx.valid(req.req)) throw new KernelError("EINVAL");
13341
13410
  if (req.ack) ctx.ops.ack(req.ack);
@@ -13358,6 +13427,17 @@ async function hostSyscall(req, ctx) {
13358
13427
  return done2;
13359
13428
  }
13360
13429
  }
13430
+ var FLOCK_KINDS = /* @__PURE__ */ new Set(["shared", "exclusive", "unlock"]);
13431
+ async function flock(req, ctx) {
13432
+ const { locks, flockOf } = ctx;
13433
+ if (!locks || !flockOf) throw new KernelError("ENOLCK");
13434
+ if (!FLOCK_KINDS.has(req.kind)) throw new KernelError("EINVAL");
13435
+ const { key, owner } = await flockOf(req.fd);
13436
+ if (req.kind !== "unlock" && req.wait)
13437
+ await locks.flocks.wait(key, owner, req.kind, ctx.blocking);
13438
+ else if (!locks.flocks.set(key, owner, req.kind)) throw new KernelError("EAGAIN");
13439
+ return done2;
13440
+ }
13361
13441
  async function recordLock2(req, ctx) {
13362
13442
  const { locks, fileKey } = ctx;
13363
13443
  if (!locks || !fileKey) throw new KernelError("ENOLCK");
@@ -17486,6 +17566,18 @@ var fileKeys = /* @__PURE__ */ new WeakMap();
17486
17566
  function identityKey(st, path) {
17487
17567
  return st.ino === void 0 ? path : `${st.dev ?? 0}:${st.ino}`;
17488
17568
  }
17569
+ var objectIds = /* @__PURE__ */ new WeakMap();
17570
+ var nextObjectId = 1;
17571
+ function objectKey(file) {
17572
+ const at = file.tty ?? file.identity ?? file.pty ?? file;
17573
+ let id2 = objectIds.get(at);
17574
+ if (id2 === void 0) {
17575
+ id2 = nextObjectId++;
17576
+ objectIds.set(at, id2);
17577
+ }
17578
+ return `object:${id2}`;
17579
+ }
17580
+ var flockHooked = /* @__PURE__ */ new WeakSet();
17489
17581
  var DYING = { ok: false, errno: "EINTR", message: "the process is being killed" };
17490
17582
  var HOSTS_ONLY = new Resolver({ routes: new Routes() });
17491
17583
  function timeLeft(req, started) {
@@ -17786,6 +17878,7 @@ var WasmProcess = class {
17786
17878
  pid: this.pid,
17787
17879
  owner: () => this.lockOwner(),
17788
17880
  fileKey: (target, kind, setting) => this.lockKey(target, kind, setting),
17881
+ flockOf: (fd) => this.flockOf(fd),
17789
17882
  locks: this.options.locks,
17790
17883
  ops: this.asyncOps,
17791
17884
  blocking: () => this.blockingSignal(cancelled),
@@ -17854,6 +17947,7 @@ var WasmProcess = class {
17854
17947
  throw new KernelError("EBADF");
17855
17948
  const known = file ? fileKeys.get(file) : this.pathKeys.get(target.path ?? "");
17856
17949
  if (kind === "close" && !file && target.path !== void 0) this.pathKeys.delete(target.path);
17950
+ if (access2 && !access2.stat) return objectKey(access2);
17857
17951
  const want = kind === "read" ? R_OK : kind === "write" ? W_OK : 0;
17858
17952
  const path = await this.lockPath(target, want);
17859
17953
  if (known !== void 0) return known;
@@ -17868,6 +17962,33 @@ var WasmProcess = class {
17868
17962
  else if (this.pathKeys.size < 4096) this.pathKeys.set(path, key);
17869
17963
  return key;
17870
17964
  }
17965
+ async flockOf(fd) {
17966
+ const open = this.fds.get(fd);
17967
+ const { locks } = this.options;
17968
+ if (locks && !flockHooked.has(open)) {
17969
+ flockHooked.add(open);
17970
+ open.onClose(() => locks.flocks.drop(open));
17971
+ }
17972
+ return { key: await this.objectKeyOf(open), owner: open };
17973
+ }
17974
+ async objectKeyOf(open) {
17975
+ const { file } = open;
17976
+ const meta = file.heldMeta;
17977
+ if (meta && "device" in meta) return `device:${meta.device}`;
17978
+ if (meta && "dir" in meta) {
17979
+ const st2 = await this.options.fs.stat?.(meta.dir).catch(() => void 0);
17980
+ return st2 ? identityKey(st2, meta.dir) : objectKey(file);
17981
+ }
17982
+ const known = fileKeys.get(open);
17983
+ if (known !== void 0) return known;
17984
+ const vfs = await file.stat?.().catch(() => void 0);
17985
+ if (!vfs || vfs.orphan) return objectKey(file);
17986
+ const st = await this.options.fs.stat?.(vfs.path).catch(() => void 0);
17987
+ if (!st) return objectKey(file);
17988
+ const key = identityKey(st, vfs.path);
17989
+ fileKeys.set(open, key);
17990
+ return key;
17991
+ }
17871
17992
  async dropRecordLocks(fd) {
17872
17993
  const { locks } = this.options;
17873
17994
  const owner = this.options.jobs?.shown(this.pid) ?? this.pid;
@@ -20148,6 +20269,7 @@ function scrubbed(inner, scrubber, idleMs = SCRUB_IDLE_MS) {
20148
20269
  },
20149
20270
  changed,
20150
20271
  ...file.tty ? { tty: file.tty } : {},
20272
+ identity: file.identity ?? file,
20151
20273
  ...file.clipboard ? { clipboard: file.clipboard } : {},
20152
20274
  ...file.write ? {
20153
20275
  write: async (bytes, signal) => {