Skip to content

fix(nfs): spill AppleDouble companion bodies to disk and expand workspace capacity to 65,536 - #636

Open
jiangkoumo wants to merge 20 commits into
Ephemeral-AI-Lab:layerfs_v2_0_0from
jiangkoumo:fix/lp73-appledouble-spill
Open

jiangkoumo wants to merge 20 commits into
Ephemeral-AI-Lab:layerfs_v2_0_0from
jiangkoumo:fix/lp73-appledouble-spill

Conversation

@jiangkoumo

@jiangkoumo jiangkoumo commented Nov 9, 2026 •

Copy link
Copy Markdown
Collaborator

摘要

本 PR 为原生 macOS loopback NFSv3 导出提供 AppleDouble(._*)伴随文件的内存索引 + 磁盘溢写,
并把客户端写出的整份 AppleDouble 镜像解码为主 inode 的属性,去掉「复制大量文件后
could not copy extended attributes: Operation not permitted」的 4,096 上限问题(#635)。

范围与数字(当前头 2e09f0295)

  • 合入的上游:layerfs_v2_0_0 @ de1d77e13。
  • 分支:16 个非合并提交 + 4 个合并提交(合并 58c548b3e、三次合入上游)。
  • 生产代码(tools/production_loc.py,core/engine/control/api 的 src + 随包 SQL,测试/文档/工具不计):
    上游 159,977 → 本分支 161,046(delta +1069,全部在 engine:74,419 → 75,488;core/control/api 不变)。
    reference absent (retired R9c; last counted 65,417 at tree 498dd1917812)。
  • 相对旧描述的更正:旧文写的「4 个提交 / +412 / 头 79e378e4f」是更早 head 的状态;之后
    fac912440(解码)、0ecb4c578(编辑与重新分类)、640a30483(合入 58c548b3e)及两轮 review 修复均已在分支里。
    「65,536」是每个 export 的占槽伴随索引数(HELD),不是 Workspace 容量;「≈20 MB/export」不再成立
    (见下「内存与准入」)。

行为

  1. 内存索引 + 磁盘溢写:每个伴随一条索引记录;body 超过 32 MiB 常驻预算(RESIDENT_BYTES)时,
    最久未用的 body 溢写到 <mounts>/.appledouble/<namespace>/<serial>(目录 0700、文件 0600,
    临时名 <serial>.<n>.tmp 后 rename 收养,pread/pwrite 每次按路径开、用完关,不按 body 持 fd)。
  2. 解码:写完整份镜像且能编码回原字节时,标记为 whole image(其中每条 ≤128 KiB 且总计 ≤512 MiB
    的条目只保留 FinderInfo + 属性,READ 时按原字节重新编码;超出解码预算的 whole image 保留客户端原字节);
    所有 whole image 不占 HELD 槽。资源叉、其它布局、半写入、主文件未知的伴随按客户端字节保存并占槽。
  3. 生命周期:主文件 REMOVE / RMDIR / 覆盖 RENAME / silly rename 立即连带回收伴随并删除溢写文件;
    关键路径外的孤儿在 HELD 满时回收;release 与 daemon 启动 reclaim 删除整个
    <mounts>/.appledouble(崩溃残留),不碰 mount home 下其它东西。
  4. 并发:CREATE 越过槽位上限释放伴随锁做孤儿回收后,会重新读取主文件与伴随名并在同一持锁内复查再插入
    (并发 GUARDED CREATE 只成功一个,其余答 NFS3ERR_EXIST);Table::aliases(serial → 硬链接别名,
    含 renamed 覆写时保留的旧当前名)让 unlink/rename 只删自己的键,不再扫 4M 条 by_name;
    HELD 满时的回收只遍历 Companions::slots(上界 HELD,表锁内取伴随锁,不复制命名空间、也不遍历 whole image);
    每个伴随的编辑由 Companion::editing 门串行,溢写轮次有上界,溢写编辑把自己的 body 写出后就地编辑,
    竞争不再回 NFS3ERR_IO;溢写后的长度与字节在一次编辑内一起记录(truncate 与 WRITE 不会留下不一致的长度)。
  5. 状态与准入:索引满时 CREATE ._* 答 NFS3ERR_NOSPC(不再是 ACCES),每次拒绝计数、
    日志每 export 至少 60 秒一条。

内存与准入(如实说明)

  • RESIDENT_BYTES(32 MiB)、DECODED_BYTES(512 MiB)、DECODED_ENTRY_BYTES(128 KiB)都是缓存预算:
    超出只改变保存形态(溢写到磁盘 / 按客户端原字节保存),不拒绝、不丢数据;
  • 磁盘用量没有单独准入:用文件系统容量本身(ENOSPC/EDQUOT → NFS3ERR_NOSPC,EFBIG、
    EACCES、EPERM、EROFS、EIO 各自映射),占槽伴随最坏为 HELD × FILE_BYTES = 65,536 × 1 MiB = 64 GiB/export
    (不占槽的 whole image 受 FILE_BYTES 与文件系统容量约束,不受 HELD 约束);
  • HELD(65,536 槽)与 FILE_BYTES(1 MiB/伴随)仍是人为总量上限,AGENTS.md 不允许其长期存在。
    去掉它们需要把伴随索引从内存搬到磁盘(CREATE/LOOKUP/REMOVE/RENAME/回收/释放全部改写),本轮未做、不做半改。三个方案请 owner 定夺:
    • 方案 A(推荐):每个 export 一个 Disposable SQLite 索引 <mounts>/.appledouble/<namespace>/index.sqlite
      (表 companion / name / inode,synchronous=OFF、单写入者),内存只留可回收的 LRU 缓存;HELD 退化为缓存提示,
      FILE_BYTES 删除、改为物理容量准入(ENOSPC/EDQUOT → NFS3ERR_NOSPC)。代价:每次 CREATE/LOOKUP/REMOVE
      多一次 DB 操作;layerfs-nfs 目前无第三方依赖,需新增 rusqlite;索引文件需补生命周期与权限(0700/0600)。
    • 方案 B:无依赖的外置哈希桶文件((parent,name) → 定长头 + 名字 + 扩展块,append + 桶级 compact)。
      不改依赖,但崩溃一致性、删除、回收、并发 compaction 都要自写,代码量与出错面都比 A 大。
    • 方案 C(过渡):保留 HELD/FILE_BYTES,在文档中明确为导出资源准入。不满足 AGENTS.md,需要 owner 明确豁免。

Breaking change

  • Rust API:layerfs_nfs::ExportConfig 新增三个公开字段(companion_spill_dir、
    companion_resident_bytes、companions_held)与构造函数 ExportConfig::writable(uid, gid);
    用结构体字面量构造的调用方需要补字段或改用构造函数。daemon 已改用构造函数。
  • 运行期:daemon 在 ExportServing::reclaim 与 release 时删除 <mounts>/.appledouble;CREATE ._*
    满额的新状态码是 NFS3ERR_NOSPC。
  • 线上格式 / Store / 数据库 schema / Commit 内容:无变化。

验证

  • Linux(x86_64,当前头 2e09f0295,cargo +1.85.1 --locked):
    • CI fast lane:rc=0,1922/1922 passed(2 slow, 1 leaky,26 skipped,62.440s);
    • CI slow lane:rc=0,146/146 passed(2 slow,11 skipped,61.361s);
    • CI mount lane(Docker + /dev/fuse):45/45 binaries passed(pass=45 fail=0);
    • PG lane(postgres:17-bookworm trust + password):6 个包共 39/39 passed(persistence 25、store-service 6、project 1、api 2、cli 2、control 3,NOT_RUN=0);
    • cargo +1.85.1 clippy --locked -p layerfs-nfs -p layerfs-daemon --all-targets -- -D warnings 干净;
    • cargo +1.85.1 fmt --all -- --check 仅上游已知基线 api/crates/layerfs-cli/tests/concurrent_reads.rs:16 有 diff,本 PR 改动文件干净;
    • python3 -B tools/check_fuser_integrity.py、python3 -B tools/check_product_boundary.py --skip-sdk、python3 -B -m unittest discover -s tools -p 'test_*.py'(182 tests)全部通过;
    • spilled_companions_outnumber_the_open_file_limit 额外独立复跑 2 次均通过(0.82s、0.65s)。
  • Durable 配置:NOT_RUN — disabled by owner until explicit reauthorization。
  • macOS 实测(13 项 NFS 回归、cp/ditto/tar、4,300 个 4 KiB 与 4,300 个 1 MiB 文件)是 79e378e4f
    时代的结论;当前头 2e09f0295 未在 macOS 上复测(本轮在 Linux 上完成)。

已知限制

  • 运行在 macOS 上时,创建文件仍会多出 ._ 伴随往返(com.apple.provenance 自动打标,每文件约 5–6 个 RPC);
  • 索引与 1 MiB/伴随上限仍在(见「内存与准入」);
  • 与 #644(b49690d11)在 engine/crates/layerfs-daemon/src/control/export.rs 存在 git 文本冲突
    (CONFLICT (content),实测 git merge-tree --write-tree 2e09f0295 b49690d11 rc=1):
    冲突在 export.rs 的 spill_root/companion_spill_dir vs #644 的 is_mounted 以及 reclaim() 中的
    mounted_under 签名;ExportConfig 接线与两处 remove_dir(serving.spill_root()) 无冲突。合并时按常规手工解决,保留 spill_root() 接线并用 #644 的新 mounted_under(&self.config.mounts, self.config.mount_table.as_deref()) 签名即可;
  • mounted_install::writes_during_a_commit_stay_live_and_reach_the_next_commit 的既有偶发失败与基线同率
    (旧描述里的 220 次抽样统计仍有效;本轮全量 mount 45/45 一次通过)。

Fixes #635
Refs Ephemeral-AI-Lab/layerfs-project#73


…budget (lp73)

- Keep AppleDouble companion index entries in export memory (HELD = 65,536, FILE_BYTES = 1 MiB) and bound resident payload bytes at RESIDENT_BYTES (32 MiB); when a write or resize crosses the resident budget, spill least recently touched resident bodies to the export's scratch directory (<mounts>/.appledouble/<namespace>/<serial>) and serve spilled reads/writes via pread/pwrite outside the companion lock, issuing 0 engine or SQLite owner jobs.

- Drop the paired ._<name> companion (and unlink its spilled file) when a main file is removed, silly-renamed, or replaced by RENAME; when HELD is reached, reclaim orphan companions whose main file is absent from Table::by_name first and return NFS3ERR_ACCES only if all 65,536 slots belong to live files.

- Clean any leftover companion spill directory on Attach/export start and remove the directory when the export stops.

LayerFS production LOC: 143682 -> 144159 (delta +477)

Production LOC: 143,682 -> 144,159 (delta +477)

Scope: core 39,491 -> 39,491 (0), engine 66,277 -> 66,754 (+477), control 33,969 -> 33,969 (0), api 3,945 -> 3,945 (0); reference absent (retired R9c; last counted 65,417 at tree 498dd19)

Method: python3 -B tools/production_loc.py (parent 716197c -> committed tree)
…n out under another call (lp73)

- Close the window the review found: spill_self switched a companion to the file it wrote out without comparing the `used` tick, so a resident body another connection wrote to during the off-lock disk write could be replaced by the older snapshot. Both spill paths now go through spill_one -> Companions::adopt_spilled, which switches only when spill_applies(used, file.used, resident) holds and otherwise unlinks the file just written so the newer body stays and the caller decides again.

- Fold the redundant state machines: spill_for and spill_self become one spill_one(serial, keep); companion_write and companion_resize share Edit (Write/Size), Room (Resident/Spilled/Grow), edit_resident (in the lock, within the resident budget), edit_companion (one off-lock disk pass, deciding again when another call changed the body) and record_edit; companion_resize is removed and SETATTR resizes through edit_companion. Unlink/removal boilerplate is shared by unlink_paths, companion_take and Kernel::holdings.

- Remove ExportConfig::with_companion_spill_dir (no caller) and Released::companions_spilled (read only by tests), counting the spill directory in the tests instead. ExportConfig keeps companion_resident_bytes and companions_held, exactly as lookups_held already does, because both bounds have to be reachable cheaply from tests; deleting them needs a doc(hidden) setter and saves about four lines net.

- Set the modes instead of relying on the daemon's umask: the scratch directory is created 0700 and each spilled file 0600. The daemon's startup reclaim now removes a leftover <mounts>/.appledouble from a daemon that ended without unmounting, before it serves any export, and touches nothing else under the mount home.

Tests: tests/write.rs asserts the scratch directory and spilled-file mode bits, and that a spill replaces a companion body only when no call changed it meanwhile; tests/daemon.rs asserts the startup reclaim clears the leftover spill tree and leaves its siblings alone. No test is deleted.

LayerFS production LOC: 143682 -> 144039 (delta +357)

Production LOC: 144159 -> 144039 (delta -120) for this commit (parent 24fa155); the +357 line above is the branch against its base 716197c, as the task asked.

Scope at the committed tree: core 39,491, engine 66,634, control 33,969, api 3,945 (this commit: apple_double.rs 715 -> 614, kernel.rs 796 -> 783, server.rs 300 -> 296, control/export.rs 841 -> 839); reference absent (retired R9c; last counted 65,417 at tree 498dd19)

Method: python3 -B tools/production_loc.py --files at the parent 24fa155 and the committed tree; first-party product source under core/engine/control/api, tests, docs and tools excluded.
…wo spill races (lp73)

Every spilled companion body was held open in `Body::Spilled(Arc<File>)`, so an
export with more spilled companions than the process's own `RLIMIT_NOFILE`
answered WRITE with `NFS3ERR_IO` (`openat EMFILE`): with Linux's default 1024,
`write::apple_double_companions_spill_preserve_all_bytes_reclaim_orphans_and_clean_up`
failed after about a thousand companions. `Body::Spilled` now only marks a body
as on disk, its path derived from the serial, and READ, WRITE and SETATTR open
it for their own `pread`/`pwrite`/`set_len` and close it again, so descriptors
follow concurrent operations instead of spilled bodies. No descriptor limit is
raised to pass a test; nothing about the file format or directory layout
changes.

Two multi-connection windows are closed with it:

- `edit_resident` records a body's bytes, length, recency tick and time under the
  same lock, so a call can no longer see new bytes with an old length (a READ
  could slice past the bytes and panic) or a stale `used` tick (a spill could
  take the older snapshot for the current body). `Room::Resident` carries the
  stat afterwards, so `record_edit` now only serves the disk half. READ clamps
  its slice to the bytes it holds instead of panicking.
- A spill writes `<serial>.<n>.tmp` (`create_new`) and adoption renames it to
  `<serial>` under the lock, so two calls spilling the same companion never
  truncate each other's file, and the call whose file is not adopted unlinks
  only its own temporary file.

New test `write::spilled_companions_outnumber_the_open_file_limit` lowers the
soft `RLIMIT_NOFILE` (restored by a `Drop` guard, `panic` included) to at most
512 and creates four times as many spilled companions, then reads all of them
byte for byte and writes and truncates one: `NFS3ERR_IO` before this change, all
green after. `libc` becomes a dev-dependency of `layerfs-nfs` for `getrlimit`/
`setrlimit`; it is already in the workspace at 0.2.189, so the lock gains one
dependency edge and no package.

LayerFS production LOC: 144039 -> 144079 (delta +40)

Scope and method: `python3 -B tools/production_loc.py` (combined total) on the
first parent and the committed tree; only
`engine/crates/layerfs-nfs/src/apple_double.rs` changed in production scope
(614 -> 654 production lines, physical 723 -> 785, under the 999-line ceiling).
Tests, the Cargo manifest/lock and `docs/architecture/engine/kernel-routes/nfs.md`
are outside that count.
…B-FIX)

Keep companion name lookup, capacity reclamation and serial insertion under one
lock. Preserve previously observed hard-link names in by_name and remove every
key for an inode when its synthetic custody is retired.

Keep HELD at 65,536. Refuse an exhausted companion index with NFS3ERR_NOSPC,
count each refusal and log export/namespace and capacity at most once per minute.
Add concurrent CREATE and hard-link reclamation regressions; update nfs.md.

Validation: layerfs-nfs fast tests 32 passed and slow test 1 passed;
write under ulimit -n 1024: 13 passed; named_mount: 1 passed;
Clippy all targets for layerfs-nfs/layerfs-daemon with -D warnings; rustfmt;
product boundary guard and its 37 self-tests; release CLI/daemon build.
Both new tests failed with their respective fixes temporarily reverted.
No kernel mount testing, push, PR or issue.

LayerFS production LOC: 144079 -> 144094 (delta +15)
Scope: first-party core/engine/control/API production source and runtime SQL;
tests, docs, tools and manifests excluded. Engine 66674 -> 66689; other
production scopes unchanged. Relative to 716197c (143682): delta +412.
Method: unchanged tools/production_loc.py at ca153a7, SHA-256
 d3148db88f91b09eb74eb659b2f700209776a24d022f09774dc7bb38ea1eecf6;
python3 -B tools/production_loc.py --root <snapshot>, identical source scope
from git archives of first parent ca153a7 and final staged tree
86c9888. The committed tree must match it.
Reference absent (retired R9c; last counted 65,417 at tree 498dd19).
Copilot AI balanced review requested due to automatic review settings November 9, 2026 04:21

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has insufficient remaining quota. Try requesting a review at a lower effort level.

An organization owner can choose the repository organization as the billing source for reviews requested by members with a paid Copilot license. Learn about Copilot code review billing.

…s (lp73)

A `._name` companion the export creates while it knows its main file now
belongs to that inode rather than to one name: every hard-link alias reaches
it, RENAME of the main file keeps it reachable, unlink or a replacing RENAME
only unbinds the paired `._` name, and the inode's last name (or its silly
rename) drops it. Its FinderInfo and extended attributes are kept under that
inode's serial, once per inode and not once per name.

After every resident write, resize or rename of such a companion, the new
codec (`apple_double_codec`) decides whether the body is a whole image in the
client's own layout: an AppleDouble v2 header whose FinderInfo entry runs into
the ATTR table, values packed in entry order, the client's empty resource fork
or none, the whole length a number of 4 KiB units. It decodes the image and
requires that encoding the decoded form gives back exactly the same bytes, so
a synthesized READ is byte for byte what the client wrote. Each attribute
entry keeps the alignment padding the client left after its name (it does not
clear what a shorter name replaced), and nothing else. A whole image takes no
slot of the 65,536-entry index bound, which is what keeps a tree's attribute
copies from exhausting it; it is kept decoded while the decoded budget takes it
(512 MiB in all, 128 KiB each), and otherwise keeps its bytes as written.

Every state of the client's write sequence is served as written: a header, a
delete and rebuild, an edit that leaves the layout incomplete, a truncation or
a half-written image stay the client's own bytes, and only a whole image is
replaced by its decoded form. A resource fork, another layout, a companion
whose main file the export never saw, or an edit to a spilled body keeps the
Ephemeral-AI-Lab#636 stored-as-written path and its slot, with the bound, NOSPC, the
rate-limited log and orphan reclamation unchanged. No attribute is dropped:
provenance, quarantine, FinderInfo and user attributes are all kept.
Persistence is unchanged: companions remain export-local and never reach the
Store or a Commit.

Released reports slotted/decoded companions, decoded bytes, and releasing an
export with companions logs them. The spill-file helpers move unchanged into
`apple_double_spill` to keep `apple_double.rs` under the file ceiling.

Tests: new `tests/apple_double_codec.rs` (hand-built images, 0 B to 100 KiB
values, many attributes, FinderInfo, resource forks, kept alignment padding,
truncated, altered and foreign bytes) and `tests/apple_double_decode.rs` (the
client's write sequence, whole images taking no slot, hard links, renames and
replacements, the silly rename, the slot bound and the stored-as-written
fallback). No test deleted.

Production LOC: 156721 -> 157237 (delta +516); scope core/engine/control/api
first-party product source; counted with tools/production_loc.py on git-archive
snapshots of the parent commit 640a304 and the staged tree; tests, docs and
tools excluded. engine 73491 -> 74007. Reference absent (retired R9c; last
counted 65,417 at tree 498dd19).
…e (lp73)

Three review findings on fac9124:

- `adopt_spilled` takes a decoded body too: an edit that leaves a whole image
  not whole (a mid-image write, a header rewrite) now spills the encoded image
  and applies the write to that file, instead of answering NFS3ERR_IO after
  three rounds. A decoded body's resident need is its edited length rather than
  its image length, so truncating it needs no room for the image and evicts no
  other body.
- `keep_decoded` and `classify` hold `file.inode.is_some()`: a companion whose
  main file the export does not know is never a whole image. It keeps the
  client's bytes as written, takes a `HELD` slot and can be reclaimed as an
  orphan, including after a RENAME onto a name whose main file is unknown (a
  decoded body is written back out as the client's bytes there).
- `Kernel::renamed` takes the source name as well and removes only
  `(from, name)` from `Table::by_name`, so an inode's other names, its hard
  links, keep reaching its companion after another name is renamed.

Tests: three new cases in `tests/apple_double_decode.rs`: a decoded companion
edited under a 1 KiB resident budget (mid-image write, truncation, rewrite, and
an unrelated resident body that must not be evicted for it); a companion with
no main file not decoded and reclaimed by the index bound; hard links a and b,
RENAME a -> c, `._b` still LOOKUPs before and after c goes. Reverting each fix
alone makes its test fail (NFS3ERR_IO; decoded=2/slotted=0; NFS3ERR_NOENT).
No tests deleted.

Docs: nfs.md records that a RENAME leaves the inode's other names reaching the
companion and that a body which stops being decodable is written back out.

Production LOC: 157237 -> 157253 (delta +16); scope core/crates, engine/crates,
control/crates and api/crates product sources (src/ and shipped SQL) counted by
tools/production_loc.py on the parent tree fac9124 and this committed tree;
test, doc and tooling changes are excluded.
…p73)

Docs only: rewraps the paragraph the previous commit extended, so its lines keep
the file's ~80 column prose wrapping. No wording or behaviour change.

Production LOC: 157253 -> 157253 (delta 0); docs only, counted with
tools/production_loc.py on the parent tree and this tree.
@jiangkoumo

Copy link
Copy Markdown
Collaborator Author

Pushed three more commits on top of the spill work (head c0c83e3d3, merged with layerfs_v2_0_0 at 58c548b3e first):

  • fac912440 decodes AppleDouble companions into their inode's own extended attributes instead of keeping them as separate held bodies.
  • 0ecb4c578 fixes review findings: decoded companions can be rewritten and truncated, companions of unknown mains stay slotted and reclaimable, and a rename with hard links only moves the renamed name.
  • c0c83e3d3 doc reflow.

Results on a native macOS NFSv3 mount: 70,000-file cp with 0 failures, 0 companions slotted, 70,000/70,000 attributes read back from a second mount; rewrite, truncate, spill and hard-link rename cases pass.

Tests: macOS layerfs-nfs 48 passed, clippy clean. Linux fmt/clippy clean, layerfs-nfs 47 passed, layerfs-daemon failures identical to baseline 58c548b3e.

The AppleDouble section describes the companion machinery but not its
remaining cost. State the known limitation: a create still pays extra `._`
companion round-trips (about 5-6 RPCs per file) because macOS auto-tags new
files with com.apple.provenance, and no server-side fix keeps xattr semantics.

Production LOC: 160911 -> 160911 (delta 0)
Scope: first-party product source in the four core, ten engine, eight control
and four api crates; tests, docs, tools and third-party code excluded.
Method: python3 tools/production_loc.py (files unchanged by this commit; a
docs-only change keeps the combined total and its delta at 0).
@yifanxuaaa

Copy link
Copy Markdown
Contributor

Review (static, at c0c83e3). Request changes. The spill design holds up: Body::Spilled holds no descriptor, nothing calls fsync, and the spill directory goes at release, on failed attach and at startup reclaim. The AppleDouble codec is bounds-safe on malformed input. But two lock-held scans scale with the whole namespace, and the description no longer matches the branch.

Blocking

  1. Every last-name unlink scans by_name (kernel.rs:491, table.by_name.retain(|_, held| *held != serial), new in this PR) under the table lock. by_name keeps names and can hold about 4M entries (NAMES_HELD), so rm -rf of N files costs N × |by_name|. A reverse map from serial to names is needed. Separately, entry_taken (:428) no longer drops a serial's old name, so stale aliases stay until unlink.
  2. With slots full, every ._ CREATE clones all of by_name (keys and values) into HashSets while holding the table lock (apple_double.rs:549-557). That is a namespace-sized allocation on each refused create, and every NFS operation stalls behind it.
  3. Scope and claims.
    • After the merge 640a304, three commits added the codec, inode-bound companions and decoded-companion editing (+532 production LOC). The body still lists 4 commits, +412 LOC and head 79e378e.
    • "Workspace capacity 65,536" is the per-export companion slot count (HELD).
    • "≈20 MB per export" no longer holds: DECODED_BYTES allows 512 MiB of resident decoded images on top of the 32 MiB body budget, and disk use has no admission (up to 64 GiB per export).
    • HELD and FILE_BYTES are artificial resident-container caps, which AGENTS.md forbids. Since bodies can spill now, the index can be backed on disk too.

To fix

  • companion_rename (apple_double.rs:719-727) re-classifies only resident or decoded bodies. A spilled whole image renamed onto a ._x whose main file is unknown ends up with inode=None, whole=true: it takes no slot and bypasses HELD.
  • edit_companion (:806) re-decides up to 3 optimistic rounds and then answers NFS3ERR_IO with no real failure behind it, and spill_for (:452) loops with no bound. A per-companion lock would serialize instead.
  • Spilled edits. The file write happens outside the lock and the length is recorded afterwards. A concurrent truncate (SETATTR size=0) and WRITE can leave len at 10 with a 0-byte file, and every read then fails NFS3ERR_IO. ca153a7 fixed this only for resident bodies.
  • spilled_companions_outnumber_the_open_file_limit lowers the process-wide RLIMIT_NOFILE to 512 and asserts the process fd count while other tests in the binary run in parallel. Either can flake.

Rules

Conflicts with #644 in control/export.rs (reclaim / mounted_under signature vs the spill-dir rewrite). It merges cleanly on the current base and with #637, #642 and #647.

Production LOC: 160911 -> 160921 (delta +10)
Scope: first-party core/engine/control/api production source and shipped
runtime SQL; tests, docs, tools and manifests excluded. Method:
python3 -B tools/production_loc.py (SHA-256
 d3148db88f91b09eb74eb659b2f700209776a24d022f09774dc7bb38ea1eecf6)
on git archives of the first parent 3ed05e4 and the merged tree
2a7dc2b98; the delta is the merged upstream's own (control +10).
Reference absent (retired R9c; last counted 65,417 at tree 498dd19).
`Kernel::drop_unlinked` retired an inode's names by scanning the whole
`Table::by_name` index (`retain(|_, held| *held != serial)`) under the table
lock, and `entry_taken` never dropped a serial's previous name, so a name a
lookup replaced stayed until the inode's last name went. `rm -rf` of N files
therefore cost N x |by_name| -- up to the index's 4M names (LP636 review,
blocking 1).

- `Table::aliases` (serial -> names) remembers a hard-linked inode's names
  beyond `Table::names`, so `unbind_serial` drops exactly one serial's keys and
  nothing scans the index.
- `entry_taken` retires the serial's old key at once when the engine reports one
  name (nlink 1): that inode cannot be reached under a second name. A
  hard-linked inode (nlink > 1) still keeps its aliases, which is why orphan
  reclamation keeps a companion when a second name of its main file was seen.
- `renamed` and the failed resolution of a collected serial drop their key
  through the same helpers; `finish_unlink` unbinds through `Table::unbind_any`
  so the alias record goes with the name.

The release counters and `Released` moved to `kernel/released.rs` (kernel.rs
would otherwise pass the 999-line ceiling); the move changes no behavior.

Tests: existing `layerfs-nfs` write/decode suites cover hard-link aliases,
rename and reclamation (13 write + 9 decode cases, all pass).

Production LOC: 160921 -> 160984 (delta +63)
Scope: first-party core/engine/control/api production source and shipped
runtime SQL; tests, docs, tools and manifests excluded. Method:
python3 -B tools/production_loc.py (SHA-256
 d3148db88f91b09eb74eb659b2f700209776a24d022f09774dc7bb38ea1eecf6)
on git archives of the first parent 3ab79db (160921) and this committed
tree 900a8dc (160984). Engine 75363 -> 75426; other scopes unchanged.
Reference absent (retired R9c; last counted 65,417 at tree 498dd19).
Four findings from the LP636 review of the AppleDouble companions, all in the
export's own index.

- Blocking 2. At the `HELD` bound every `._` CREATE copied all of
  `Table::by_name` into two HashSets (keys and values) -- a namespace-sized
  allocation on each refused create, taken under the table lock. Reclamation
  now walks the companion index instead: `Kernel::orphans` takes the table
  lock and, inside it, the companion lock (never the other order), asks each
  slotted companion whether its main inode still holds a name (O(1) through
  `Table::names`/`Table::aliases`, which the previous commit added) or, for one
  whose main file was never seen, whether its main name is still in `by_name`,
  and returns only the orphans. Work is bounded by the index, not the
  namespace.
- A spilled whole image survived a RENAME onto a name whose main file is
  unknown as `inode=None, whole=true`, taking no slot and bypassing `HELD`.
  `companion_rename` classifies every body again under its destination, and
  `classify` keeps a body on disk's class only while the inode is known: an
  unknown destination makes it slotted. (A spilled body cannot be decoded
  there; that would read its file under the companion lock.)
- `edit_companion` re-decided up to three optimistic rounds and then answered
  `NFS3ERR_IO` with no failure behind it, and `spill_for` looped with no bound.
  Each companion now carries its own edit gate (`Companion::editing`), held for
  the whole edit -- resident or disk -- so no other edit of that companion
  interleaves. `spill_for` bounds its rounds by the index and the caller writes
  out its own body (through `spill_gated`, which the gate makes authoritative)
  when it still does not fit, so no edit is refused for contention.
- The spilled-edit length race (`ca153a7f6` fixed it for resident bodies): a
  concurrent truncate and WRITE left `len` at 10 with a 0-byte file, and every
  read then failed `NFS3ERR_IO`. With the gate, each edit's length is recorded
  with the bytes its own edit left, so the pair can only serialize.

The body path (READ, WRITE, SETATTR, spill) moved to `apple_double/edit.rs`;
`apple_double.rs` stays under the 999-line ceiling.

New tests:
- `apple_double_decode::a_spilled_body_renamed_off_its_inode_takes_its_slot`
  (spilled whole image renamed onto an unknown main must take its slot).
- `write::a_spilled_companion_keeps_its_length_and_bytes_together` (a truncate
  and a write of one spilled companion, 64 rounds: the reported length always
  matches the bytes served).

Docs: nfs.md records the reverse index, the per-companion edit gate, the
bounded spill rounds and the RENAME reclassification.

Tests: `layerfs-nfs` write 14 passed, decode 10 passed (nextest). Clippy
`-p layerfs-nfs -p layerfs-daemon --all-targets -- -D warnings` clean; rustfmt
clean for the changed files (the two upstream files listed in the LP636-CLOSE
report still differ, unchanged here).

Production LOC: 160984 -> 161033 (delta +49)
Scope: first-party core/engine/control/api production source and shipped
runtime SQL; tests, docs, tools and manifests excluded. Method:
python3 -B tools/production_loc.py (SHA-256
 d3148db88f91b09eb74eb659b2f700209776a24d022f09774dc7bb38ea1eecf6)
on git archives of the first parent 5851b52 (160984) and this committed
tree 8dffa04 (161033). Engine 75426 -> 75475; other scopes unchanged.
Reference absent (retired R9c; last counted 65,417 at tree 498dd19).
`spilled_companions_outnumber_the_open_file_limit` lowered the process-wide
soft `RLIMIT_NOFILE` to 512 and asserted the process descriptor count while the
other tests of the same binary ran in threads beside it: either assertion could
flake (LP636 review).

The test now re-executes its own binary with `--exact` for this one test in a
child process (`LAYERFS_NFS_FD_LIMIT_CHILD` marks the child), so the limit and
the descriptor count belong to a process running nothing else. The parent
asserts the child's exit status. Libtest and nextest both start the same test
binary, so the child is selected by name in either.

Two regression tests for the companion fixes:
- `a_spilled_body_renamed_off_its_inode_takes_its_slot`: a whole image kept as
  written (attribute above the decoded-entry bound), evicted to disk by another
  companion's body, then renamed onto a name whose main file the export never
  saw. Before the reclassification it kept `whole` and took no slot.
- `a_spilled_companion_keeps_its_length_and_bytes_together`: a truncate and a
  write of one spilled companion start together 64 times; the length read back
  always matches the bytes served. Without the per-companion edit gate the
  length can be left at the write's size over a truncated file, and the read
  then answers NFS3ERR_IO.

Tests: `layerfs-nfs` write 14 passed, decode 10 passed (nextest); the other
layerfs-nfs binaries were run separately (31 passed).

Production LOC: 161033 -> 161033 (delta 0)
Scope: first-party core/engine/control/api production source and shipped
runtime SQL; tests, docs, tools and manifests excluded. Method:
python3 -B tools/production_loc.py (SHA-256
 d3148db88f91b09eb74eb659b2f700209776a24d022f09774dc7bb38ea1eecf6)
on git archives of the first parent b4d0222 and this committed tree; only
test files changed. Reference absent (retired R9c; last counted 65,417 at tree
498dd19).
…nk (lp73)

`Companion` is not in the body module's scope, so the module documentation
linked a private item that rustdoc cannot resolve there. The gate is named in
the parent module anyway.

Production LOC: 161033 -> 161033 (delta 0)
Scope: first-party core/engine/control/api production source and shipped
runtime SQL; tests, docs, tools and manifests excluded. Method:
python3 -B tools/production_loc.py (SHA-256
 d3148db88f91b09eb74eb659b2f700209776a24d022f09774dc7bb38ea1eecf6);
only comment lines changed, so the count is unchanged.
Reference absent (retired R9c; last counted 65,417 at tree 498dd19).
…aliases (lp73)

- N1: at the bound CREATE releases the companion lock to reclaim orphans;
  it now reads the name and its main file again and inserts under the lock
  it holds once more, so a concurrent GUARDED CREATE of one name yields one
  serial and one NFS3ERR_EXIST (before: a second serial for the same name,
  or a spurious NFS3ERR_NOSPC once the other call had inserted).
- N2: renamed() records the recalled name it overwrites, when that name did
  not move and is still one of the inode's, as an alias, so retiring the
  serial drops every key of by_name it holds.
- N3: reclamation walks Companions::slots, the companions that take a slot,
  instead of the whole index. A whole image takes no slot and its number is
  bounded by the decoded bytes, not by HELD, so it no longer sets the walk's
  cost. nfs.md's CREATE, alias and reclamation paragraphs now say that.
- Tests: a_guarded_create_reclaims_the_last_slot_for_one_entry_only and
  a_renamed_hard_link_alias_keeps_the_inodes_recalled_name_bound fail on
  the unfixed code (round 20 answers NOSPC instead of EXIST; the companion
  is no longer found), whole_images_take_no_slot_and_the_bound_reclaims_
  only_slots pins the slot accounting reclamation reads.

Production LOC: 161033 -> 161046 (delta +13)
Counting: tools/production_loc.py --root <snapshot> --json, SHA-256
d3148db88f91b09eb74eb659b2f700209776a24d022f09774dc7bb38ea1eecf6; scope
core/engine/control/api src plus shipped SQL, tests, docs and tools excluded;
parents a7b6ffd (161033) and the staged tree (161046); layerfs-nfs
apple_double.rs 549 -> 562, kernel.rs 777 -> 777. reference absent (retired
R9c; last counted 65,417 at tree 498dd19).
cargo +1.85.1 fmt -p layerfs-nfs. The other files this branch changes were
rustfmt'd in the commits that changed them; after this commit cargo fmt --all
-- --check reports only the two upstream files (api/crates/layerfs-cli/tests/
concurrent_reads.rs, control/crates/layerfs-store/src/lease.rs), neither of
which this branch touches.

Production LOC: 161046 -> 161046 (delta 0)
Counting: tools/production_loc.py --root <snapshot> --json, SHA-256
d3148db88f91b09eb74eb659b2f700209776a24d022f09774dc7bb38ea1eecf6; scope
core/engine/control/api src plus shipped SQL, tests, docs and tools excluded;
parents the previous commit's tree (161046) and the staged tree (161046);
layerfs-nfs apple_double/edit.rs 298 -> 298 (physical lines 367).
reference absent (retired R9c; last counted 65,417 at tree 498dd19).
Whole images exceeding DECODED_ENTRY_BYTES or DECODED_BYTES stay as written
bytes rather than Body::Decoded, so DECODED_BYTES bounds only the decoded
subset while all whole images stay outside HELD and Companions::slots.

Production LOC: 161046 -> 161046 (delta 0)
Counting: tools/production_loc.py --root <snapshot> --json, SHA-256
d3148db88f91b09eb74eb659b2f700209776a24d022f09774dc7bb38ea1eecf6; scope
core/engine/control/api src plus shipped SQL, tests, docs and tools excluded;
parents 0a4dfdd (161046) and the staged tree (161046); comment-only change
in layerfs-nfs apple_double.rs. reference absent (retired R9c; last counted
65,417 at tree 498dd19).
…(lp73)

Brings in upstream/layerfs_v2_0_0 at de1d77e (Ephemeral-AI-Lab#671 architecture docs
reorganization and two daemon test updates). Conflict in
docs/architecture/engine/kernel-routes/nfs.md keeps the upstream Ephemeral-AI-Lab#671
structure here; the AppleDouble spill/decode doc updates from this branch
are ported in the next commit.

Production LOC: 161046 -> 161046 (delta 0)
Counting: tools/production_loc.py --root <snapshot> --json, SHA-256
d3148db88f91b09eb74eb659b2f700209776a24d022f09774dc7bb38ea1eecf6; scope
core/engine/control/api src plus shipped SQL, tests, docs and tools excluded;
first-parent 778d831 (161046) and merged tree (161046); upstream brought
docs and test-only changes. reference absent (retired R9c; last counted
65,417 at tree 498dd19).
…I-Lab#671 nfs.md (lp73)

Topics updated: engine/kernel-routes (nfs.md).
Ports this branch's ExportConfig fields, companion wcc_data rule, and
AppleDouble spill/decode/concurrency/reclamation bounds and behavior
into the restructured Ephemeral-AI-Lab#671 nfs.md layout, including the N3 clarification
that whole images kept as written bytes above the decoded budget also
take no HELD slot.

Production LOC: 161046 -> 161046 (delta 0)
Counting: tools/production_loc.py --root <snapshot> --json, SHA-256
d3148db88f91b09eb74eb659b2f700209776a24d022f09774dc7bb38ea1eecf6; scope
core/engine/control/api src plus shipped SQL, tests, docs and tools excluded;
parents 22b8df2 (161046) and the staged tree (161046); docs-only change.
reference absent (retired R9c; last counted 65,417 at tree 498dd19).
@jiangkoumo

Copy link
Copy Markdown
Collaborator Author

感谢审阅。逐条回复如下(当前头 2e09f0295,已合入上游 de1d77e13 并完成全量 fast/slow/mount/PG 验证):

Blocking 1(unlink 扫 by_name)——已改。 Table 新增 aliases(serial → 硬链接别名)与
bind_name/unbind_name/unbind_any/remember_alias/unbind_serial;drop_unlinked 不再
retain 全表,只删该 serial 的键。entry_taken 也按建议清旧名:当引擎报 nlink <= 1 时旧键立即丢,
只有硬链接(nlink > 1)才保留别名。renamed() 在覆盖 names[serial] 时若旧当前名未移动且仍为该
inode 的别名,也会补记进 aliases,确保最后 unlink 时 by_name 不留任何陈旧键;resolve 与
change.rs:467(finish_unlink)走同一组 helper。回归测试:
a_renamed_hard_link_alias_keeps_the_inodes_recalled_name_bound(tests/write.rs:916)。
文件:src/kernel.rs、src/change.rs:467;Released/释放计数搬到 src/kernel/released.rs。

Blocking 2(槽满时克隆整个 by_name)——已改。 新增 Kernel::orphans(src/apple_double.rs:443):
在表锁内取伴随锁(全文件唯一锁序),只遍历占槽条目集合 Companions::slots(严格受 HELD 约束,
不占槽的 whole image 不在遍历范围内),用 Table::names/Table::aliases 判断主 inode 是否还有名字、
用 by_name 判断未观察主文件是否还在;只返回孤儿。companion_create 释放伴随锁回收后,会重新读主文件、
重新持伴随锁并再次 resolve(&named, main),已存在则走 companion_open(并发 GUARDED 只成功一个,
其余答 NFS3ERR_EXIST)。旧 reclaim_orphans 与两份 HashSet 快照已删除。回归测试:
a_guarded_create_reclaims_the_last_slot_for_one_entry_only(tests/write.rs:853)、
whole_images_take_no_slot_and_the_bound_reclaims_only_slots(tests/apple_double_decode.rs:643)。

Blocking 3(描述与人为上限)——描述已按当前头重写;HELD/FILE_BYTES 本轮未改,给方案。
当前相对 de1d77e13 为 16 个非合并提交 + 4 个合并提交,生产 LOC 159,977 -> 161,046 (+1069);
明确「65,536 是每 export 的占槽伴随索引槽(HELD)」,删掉「≈20 MB/export」,写明
RESIDENT_BYTES/DECODED_BYTES/DECODED_ENTRY_BYTES 为缓存预算,占槽伴随磁盘最坏 64 GiB/export
(超解码预算的 whole image 保留原字节且不占槽,受 FILE_BYTES 与文件系统容量约束)。
HELD/FILE_BYTES 确实是人为上限。去掉它们要把伴随索引落盘,会改写全套读写路径,本次不做半改,请你们在三个方案里定一个:

  • 方案 A(我们推荐):每个 export 一个 Disposable SQLite 索引(<mounts>/.appledouble/<namespace>/index.sqlite),内存只留 LRU 缓存,FILE_BYTES 改为物理容量准入;代价是 layerfs-nfs 新增 rusqlite 依赖、元数据操作多一次 DB 访问。
  • 方案 B:无依赖的磁盘哈希桶文件,不加依赖,但崩溃一致性与 compaction 都要自写,代码更多、更易出错。
  • 方案 C:暂保留上限,文档标为导出资源准入,需要你们明确豁免 AGENTS.md 这一条。
    定了之后我们另开提交(或后续 PR)实现。

To fix 1(companion_rename 对 spilled whole image)——已改。 companion_rename 对所有 body 调
classify;classify 在 inode == None 时把 whole 清 false(磁盘 body 保留文件并重新占槽),
inode == Some 时保留原来的分类(锁内不读盘解码)。回归测试
a_spilled_body_renamed_off_its_inode_takes_its_slot(tests/apple_double_decode.rs:578)。

To fix 2(edit_companion 3 轮后 NFS3ERR_IO、spill_for 无界)——已改。 每个伴随带自己的
editing 门,整个编辑持门;spill_for 轮次上界 = files.len(),腾不出预算就由调用方
spill_gated 把自己的 body 写出(持门,adopt_spilled 跳过 used 比较)后就地编辑,
不再有竞争导致的 NFS3ERR_IO。文件:src/apple_double/edit.rs。

To fix 3(spilled 编辑的长度竞态)——已改。 同一把门覆盖磁盘写与长度记录(record_edit 在门内),
truncate 与 WRITE 只能串行,不会出现 len=10、文件 0 字节。新增不变量测试
a_spilled_companion_keeps_its_length_and_bytes_together(tests/write.rs:1065,64 轮并发)。
(备注:读路径 companion_read 仍未持编辑门,与旧代码一致,并发 truncate 时单次读偶有瞬时 NFS3ERR_IO,不会留下永久不一致。)

To fix 4(RLIMIT 与并行测试)——已改。 spilled_companions_outnumber_the_open_file_limit
现在通过 run_alone 以 --exact 在子进程中独立执行,RLIMIT_NOFILE 与 fd 计数仅影响子进程。

Rules。 新建的合并提交(3ab79db3d、22b8df211)及后续全部新提交都带 Production LOC: <before> -> <after> (delta ...)
(历史提交未改写)。所有改动文件物理行数均 < 999(最大 kernel.rs 969、control/export.rs 953),lib.rs 39 行。

#644 冲突。 在当前头 2e09f0295 与 #644(b49690d11)上实测 git merge-tree --write-tree:
rc=1,仅在 engine/crates/layerfs-daemon/src/control/export.rs 有两处文本冲突(spill_root/companion_spill_dir
定义位置 vs #644 的 is_mounted,以及 reclaim() 中 mounted_under 的一参/二参签名),不会静默丢代码;
ExportConfig 接线与两处 remove_dir(serving.spill_root()) 无冲突。合并时保留 spill_root() 接线并用 #644 的新 mounted_under 签名即可;本分支按规则不解。

@yifanxuaaa yifanxuaaa left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review record

  • Head reviewed: 2e09f0295f. It merges cleanly with layerfs_v2_0_0 at 27920d3bb6.
  • Tier: P1, gate: yes. Risks:
    • risk:kernel;
    • risk:deps (new libc dev-dependency of layerfs-nfs);
    • risk:security (new unsafe getrlimit/setrlimit in tests/write.rs);
    • risk:delete, added by hand (remove_dir_all of <mounts>/.appledouble at reclaim, Attach and release).
  • Read: the whole production diff, and the tests scanned for weakened assertions (none found). Re-run: tools/check_product_boundary.py PASS; tools/production_loc.py gives 159977 -> 161046 (+1069, all engine). Not run: cargo, any mount.

Verified as correct:

  • Codec: byte-exact round trip (compact requires encode() == input), and parsing is bounds-safe.
  • Spill files: paths are built from numeric ids only, so no traversal; files are 0700/0600, with no fsync and no space reserved ahead.
  • Concurrency: Server::stop joins connections before release, and the lock order is consistent.
  • Reclaim: limited to the per-Sandbox mount home.

Owner decisions needed before code review continues

  1. Artificial caps. HELD = 65,536 and FILE_BYTES = 1 MiB (apple_double.rs:68-70) are total caps. The repository rules forbid these without owner approval (your options A/B/C).
  2. New on-disk storage class. <mounts>/.appledouble/ lives outside the Workspace overlay database and daemon memory.
  3. Resident memory that is never evicted. DECODED_BYTES = 512 MiB per export (:74).

Findings

1. The slots ≤ HELD bound is not enforced (blocking).

  • The doc comment (apple_double.rs:148-152) and nfs.md say the walk is HELD-bounded.
  • attach (:202-205) inserts every slotted companion with no admission check. It is reached from edit_resident/classify (whole becomes false on a partial or non-compact body), from record_edit (edit.rs:253), and from a rename onto an unknown main.
  • Scenario: copy 100k files (all whole images, 0 slots), then add resource forks or rewrite them. slots grows past HELD with no NOSPC, and every orphans() walk (:443) under the table lock then scales with it.
  • Fix: admit at the point a companion becomes slotted, or drop the bound claim and bound the walk another way.

2. Eviction is an O(n) scan under the companion lock, repeated (blocking).

  • oldest_resident (:369) scans every companion, whole images included.
  • spill_for (edit.rs:271) can repeat it within SPILL_ROUNDS (edit.rs:36), so the worst case is O(n²) work while holding the lock.
  • A lost adoption race in spill_for is discarded and the pick is made again. The no-retry rule may cover that re-attempt.
  • Fix: keep an ordered recency index (for example a BTreeMap<tick, serial>) of resident bodies.

3. Rename can exceed the resident budget. When a decoded body is renamed onto an unknown main, classify (:294) turns it back into resident bytes with no budget check or spill.

4. Live data can be reclaimed as an orphan. A companion created while its main name is missing from by_name (the name index full) gets inode = None. orphans() then treats it as dead and reclaims it while the main file still exists, losing its attributes.

5. Disk I/O under the companion lock. adopt_spilled (:339) runs fs::rename under the companion lock. The module doc and nfs.md say file I/O never holds it.

6. Spurious READ errors. companion_read uses read_exact_at (edit.rs:77) with a length read before the edit gate. A concurrent truncate gives NFS3ERR_IO. Use read_at and return a short read.

7. Full re-encode on every READ. A decoded image is re-encoded in full under the lock on each READ.

Size and history

  • At +1069 production lines in gate-risk code, the PR is past the policy's ~800-line guide. Please split it, for example: codec, then spill, then edit gate and name index.
  • 5851b529a mixes moving Released into kernel/released.rs with a behaviour change. Land the move as its own relocation commit.
  • The title says "expand workspace capacity". These attributes live only in daemon memory and spill files and never reach the Workspace or a Commit, so a title like "AppleDouble companions beyond 4,096 per export" fits better.

Policy

  • The PR body has no Tier: line.
  • The macOS evidence is from c0c83e3d3, from before the edit-gate, alias and reclamation rewrites. The Linux mount lane does not exercise the macOS AppleDouble path, so the feature has no real-client proof at this head.

Reviewer: Claude Opus 5.5 (independent-context review, finding 1 spot-checked against the code). Requesting changes for findings 1 and 2, the split, and the owner decisions above.

@yifanxuaaa

Copy link
Copy Markdown
Contributor

Owner decisions for #636

Context: LayerFS does not support extended attributes (the FUSE route answers NotImplemented). These companions are an export-local shim so that macOS copies don't fail. They live for one mount, are deleted at release, and never reach the Workspace or a Commit. The decisions are sized to that.

1. HELD (65,536) and FILE_BYTES (1 MiB): option C, as a scoped waiver

Keep both limits as a documented admission limit of this export-local shim. A full index answers NFS3ERR_NOSPC, counted and logged as it is now. Option B is rejected. Option A (the per-export SQLite index) is deferred, and will be revisited only if NOSPC shows up in real use. Note for later: rusqlite is already pinned in the workspace, so A would add a dependency edge to layerfs-nfs, not a new crate.

Condition: the limit must actually hold. Today attach adds slotted companions without an admission check: from classify/edit_resident, from record_edit, and from a rename onto an unknown main (finding 1 of the review). Add the admission check where a companion becomes slotted, and add a test that exceeds HELD through that path.

2. <mounts>/.appledouble/: approved

It is a per-mount, disposable area under the Sandbox's own mount home, with the same rules as the overlay file. Directories are 0700 and files 0600; there is no fsync and no space reserved ahead. It is deleted at release and at daemon startup.

Conditions:

  • Document it in the architecture docs (kernel routes and NFS) and the cleanup docs as a new disposable area.
  • Keep the test showing that reclaim removes only .appledouble and nothing else under the mount home.

3. DECODED_BYTES (512 MiB per export, never evicted): rejected

Many Workspaces can be mounted at once, so this does not fit. Bring decoded images under the same resident budget (RESIDENT_BYTES, 32 MiB). An image that does not fit is kept as the client's raw bytes, which spill like any other body. Alternatively, cut the decode budget to a few MiB per export.

Also required before merge

  • The two other blocking findings in the review: eviction must not be an O(n) scan under the companion lock (use an ordered recency index), and a rename must not exceed the resident budget.
  • Split the PR: codec, then spill, then edit gate and name index. Land the Released move as its own relocation commit.
  • Retitle it, for example "AppleDouble companions beyond 4,096 per export". It does not change Workspace capacity.
  • Re-run the macOS proof at the final head, and add the Tier: line.

(Drafted with Claude Opus 5.5 from the review above.)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants