Repository navigation
fix(ant-devnet): stop cleanly on SIGTERM and use a per-run data dir by default - #263
Conversation
…y default
ant-devnet waited only for Ctrl+C. SIGTERM, from `kill`, a service
manager, `ant dev stop` or `ant dev start`'s own wait timeout, kept its
default action and killed the process outright: the devnet was never
shut down, its Anvil was orphaned still holding its port, and the data
directory was left behind. Interrupted runs piled up until `ant dev
start` hung for good (ant-sdk#73).
ant-devnet now installs SIGINT, SIGTERM and SIGHUP handlers (Ctrl+C
and Ctrl+Break on Windows) before it starts anything. A stop during
EVM setup drops the setup and the Anvil it started. A stop during
startup cancels the start through the devnet's own token
(Devnet::cancellation_token, new), so `start` returns at its next safe
point with every node it started registered and `shutdown` stops them
all before removing the data directory. Dropping the start future
part-way instead left an unregistered node writing into the directory
it was removing (about 1 run in 18 in testing). A start that fails also
shuts down what it started. Anvil is stopped after the nodes.
The default data directory was the whole ant data root
(`~/.local/share/ant` on Linux), removed on every graceful shutdown. On
Linux and macOS that is the same directory as ant-core's data_dir, so
Ctrl+C on a default devnet deleted the client's payment caches, upload
history and peer cache, plus any real node's data under `nodes/`.
DevnetConfig::default() now uses a fresh directory per run,
`{data root}/devnets/{pid}-{random}`, so a run never picks up what an
interrupted one left behind and only ever removes its own directory.
Shutdown also refuses to remove the data root or any of its ancestors,
even when it is passed explicitly with --data-dir.
Tests:
- unit: per-run default dirs under devnets/; the root and its ancestors
are never removed; a cancelled devnet stops pausing at once.
- tests/devnet_lifecycle.rs (unix, new CI step): runs the real binary
with HOME and XDG_DATA_HOME in a temp dir. A SIGTERM while running
(minimal preset + Anvil) exits 0, removes the data dir and frees
Anvil's port. A SIGTERM during startup (small preset, 1 s spawn delay)
exits 0, removes the data dir and writes no manifest.
Closes #261
Closes V2-1777
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
MaidSafe-Hermes
left a comment
There was a problem hiding this comment.
Code Review Summary
Verdict: Changes requested (1 confirmed blocker)
PR: #263 — fix(ant-devnet): stop cleanly on SIGTERM and use a per-run data dir by default
Coverage: 2/2 seats reviewed (sol-1 gpt-6.1-sol xhigh; glm-1 z-ai/glm-5.3). The seats disagreed: sol-1 found changes required; glm-1 approved but also noted the lexical-path guard risk. I independently verified the deletion path described below, so this blocks approval.
Changes required
src/devnet.rs:386—contains_data_rootcompares rawPathcomponents. A--data-dirsuch as<ant-data-root>/..is not recognized as an ancestor by this lexical check, butremove_dir_allresolves it to the parent and deletes the ant data root and other contents there. I reproduced the sameremove_dir_allbehavior in an isolated temporary directory. Canonicalize/normalize the target and protected root before deletion, fail closed if safety cannot be established, and add tests for.., relative paths, and symlink aliases.
CI and verification
- CI passes for tests, builds, clippy, format, docs, and storage checks.
Security Auditfails oncrypto-bigint 0.5.5/RUSTSEC-2026-0387;Cargo.lockis byte-identical at base and head, so this is inherited. - The local
cargo test --locked --test devnet_lifecycle -- --test-threads=1run exceeded the tool timeout; I could not verify a local run.
No merge action taken.
Hermes on #263: the check compared paths as written, so a --data-dir of `<data root>/..` was not seen as the root's parent, yet remove_dir_all resolves the `..` and deletes the root with everything else there. A relative path or a symlink could disguise it the same way. The check now resolves both paths before comparing: absolute, with `..` and symlinks followed. The part of a path that exists is canonicalized, and the components below it that don't exist yet are applied lexically, which is exact because a missing path can't be a symlink. So a data root that doesn't exist yet is still protected. It fails closed: if either path can't be resolved, nothing is removed. Tests: `..` in several forms, including below a directory that doesn't exist yet; a data root that doesn't exist yet; a relative path to an ancestor; symlinks to the root, to an ancestor, and to a run directory (which may be removed). End to end, on a Linux dev box: the real binary with --data-dir `<root>/..` and SIGTERM exits 0, logs that it won't remove the directory, and leaves the root and a sentinel file in place. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
MaidSafe-Hermes
left a comment
There was a problem hiding this comment.
Code Review Summary
Verdict: Changes requested (2 confirmed data-loss paths; 1 test-cleanup issue)
PR: #263 — fix(ant-devnet): stop cleanly on SIGTERM and use a per-run data dir by default
Coverage: 2/2 seats reviewed (sol-1 gpt-6.1-sol xhigh; glm-1 z-ai/glm-5.3). They disagreed: sol-1 found changes required; glm-1 approved. I reproduced both filesystem bypasses below in isolated temporary directories, so approval is blocked.
Changes required
src/devnet.rs:1302-1310—resolvetreats everyNotFoundas a missing path component. A dangling symlink also producesNotFound, so the code backs up past it and appends the symlink name lexically. In a probe, a protected path/tmp/alias/antwith/tmp/alias -> /tmp/victim/not-yetresolved lexically;covers(/tmp/victim, /tmp/alias/ant)returned false, and the cleanup removed an unrelated sentinel under/tmp/victim. Detect and resolve dangling symlinks, or fail closed when an existing component cannot be resolved; add a regression test that asserts cleanup preserves the ancestor contents.src/devnet.rs:582-589— the guard andremove_dir_allresolve the configured path in separate operations. An intermediate symlink can be retargeted aftermay_remove_data_rootapproves the path but before the async deletion runs. I reproduced the sequence in a temp directory: check a path through a symlink to a safe directory, retarget it to the protected root, then the sameremove_dir_allpath deletes the protected sentinel. Make validation and deletion operate on the same directory identity, using handle-relative/no-follow removal or an equivalent race-safe method; add a regression test for ancestor-symlink replacement.
Follow-up: test cleanup
tests/devnet_lifecycle.rs:552-557— on a test failure,DropSIGKILLs onlyant-devnet. The running test also starts Anvil; killing the parent prevents itsTestnet/AnvilInstancedestructor from running, so Anvil can remain orphaned. Clean up the process group or otherwise reap descendants on failure.
No merge action taken.
…ed (review round 2)
Hermes round 2 found two ways past the data-root check: a dangling
symlink resolved lexically, and a symlink retargeted between the check
and remove_dir_all. Rather than harden a check on arbitrary paths, a
devnet now deletes only a directory it can show it created:
- Each run creates its own {pid}-{random} directory inside
DevnetConfig::data_dir (--data-dir) with an exclusive mkdir, takes an
exclusive lock on .ant-devnet-run.lock for as long as it lasts, then
writes .ant-devnet-run.json naming the run. --data-dir is now the
parent: it, and anything else in it, is never deleted.
- Shutdown deletes the run directory only if its marker still names the
run. On Unix every step goes through a handle on the parent, and
deletion walks the tree through O_NOFOLLOW handles, so retargeting a
symlink above the run cannot redirect it.
- A new run first removes run directories earlier runs left behind
(SIGKILL, crash): marked, not kept (--no-cleanup), and with a free
lock. The OS releases the lock however a process ends. Anything
without a marker is never touched.
- The resolve/covers path check is gone.
- The devnet_lifecycle Drop guard sends SIGTERM first, so ant-devnet
stops Anvil itself, and kills Anvil before falling back to SIGKILL.
Devnet::config().data_dir still names the run directory after new(), so
the manifest data_dir and ant-core LocalDevnet see what they did before.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Clippy on CI's stable (rustc 1.99.1) denies assert_is_empty, which the 1.95 toolchain does not have. assert_eq! also prints what was left. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
MaidSafe-Hermes
left a comment
There was a problem hiding this comment.
Code Review Summary
Verdict: Changes Requested 🔴 (2 blocking findings, 2 warnings)
PR: #263 — fix(ant-devnet): stop cleanly on SIGTERM and use a per-run data dir by default
Author: @Nic-dorman
Files changed: 6 (+1,372 / -26)
Reviewed head: 7ee4dd77d6c3ab7e5db9ae2be6c202f4250eb2e6 against 02c8682466df5c0c6723f32c46223a572f020ed4
The prior .. escape and Unix dangling-symlink protections are improved. The full current diff still has these data-safety gaps. The two scoped reviews covered the run-directory and lifecycle changes with two seats each. I confirmed the blocking paths against the current source; the seats split on whether the Devnet drop path blocks.
🔴 Critical
-
src/devnet/run_dir.rs:151-165— removal can delete a replacement directory.RunDirstores the parent and run name, but not the original run-directory handle.remove()reopens the current entry by name and accepts a plain-text marker with the samerun_id. If the original directory is renamed and a replacement is created under that name with the marker copied, cleanup accepts and deletes the replacement, including unrelated files. I reproduced this sequence. Keep and verify the original directory identity through deletion; add a replacement-with-copied-marker test. The non-Unix branch has a related path-retarget race: it validates a path, drops the validated handle, then callsremove_dir_allon that path (:455-483). The path-based race is source-confirmed; I reproduced the same check-then-reopen sequence on Linux, not on a Windows host. Use identity-safe deletion there too. -
src/devnet.rs:1295-1301andsrc/devnet/run_dir.rs:245-258— dropping a startedDevnetreleases its run lock while node tasks can continue.Devnet::dropcancels the token and aborts the health monitor, but it does not stop the protocol task. Dropping that task's TokioJoinHandledetaches it; the task retains the P2P node. A laterDevnet::newwith the same parent sees the now-free lock and can sweep the still-active run directory. This is a public library path even thoughant-devnet's normal CLI path awaits shutdown. Keep the run lease until all node writers stop, or make the sweep preserve current-process runs; test dropping a started instance followed by constructing another.
⚠️ Warnings
-
src/devnet/run_dir.rs:376-397— interrupted cleanup can strand data. Cleanup removes the marker and lock along with the node data in directory enumeration order. If the process is killed or a later removal fails after those metadata entries are removed, later sweeps cannot verify or lock the partial directory and will leave it behind. Remove node contents first and delete ownership metadata last; add a fault-injection test. -
tests/devnet_lifecycle.rs:38-46— failure cleanup can leave Anvil running. The cleanup guard returns once the parent process has exited. If the test then fails because the Anvil port is still open, the guard no longer kills Anvil. Track and clean the child independently (or use a process group), including after the parent exits.
✅ Looks Good
- The new per-run data root avoids deleting the shared ant data directory.
- Signal handling and cancel-aware startup are covered by the current CI test suite.
Verification
- Current-head CI build, tests (Linux/macOS/Windows), formatting, Clippy, MSRV, storage, WebRTC Direct, documentation, and repository checks pass.
- The separate Security Audit check fails; it is not part of this code verdict, but remains relevant to merge decisions.
Cargo.lockis unchanged in this PR. git diff --checkpasses. The local targeted run-directory test did not complete: its process received SIGTERM during dependency compilation, so I do not count it as a local test result.- The new Windows signal branch compiled in CI; the lifecycle integration test is Unix-only, so I did not verify Windows signal handling at runtime.
Reviewed by Hermes Agent
…ted it (review round 3) Hermes round 3 on #263: - Removal reopened the run directory by name and trusted a marker with the right run id, so a directory put in place of a moved one, with the marker copied, was deleted. A run now keeps the directory it created open until it deletes it, deletes through that handle, and first checks that the name still refers to it. The marker also records the directory's identity (device and inode; volume serial and file index on Windows), so the sweep ignores a copy of a marker in any other directory. - Windows was path-based: check, then remove_dir_all by path. The parent's links are now resolved once, and the parent and the run directory are held open without delete sharing, which keeps them and every directory above them from being renamed or removed, so the paths built on them stay put. Identity comes from winapi-util, already in Cargo.lock. - Dropping a started Devnet without shutdown released the lock while its nodes' tasks, which hold them, kept writing, so a later sweep could delete the directory under them. Dropped without shutdown, a devnet now keeps the lock until the process exits. - Deletion now removes the data first and the marker and lock last, so one that stops part-way leaves a directory the next sweep still recognises and finishes. - The lifecycle test's guard gave up once ant-devnet had exited, leaving any Anvil it left running. ant-devnet now runs in its own process group, which Anvil joins, and the guard kills what is left of the group, however the test ends. New tests: a replacement carrying copied marker and lock is refused (Unix); on Windows the run directory and parent cannot be moved while the run lasts; a junction retargeted above the run does not redirect removal (Windows); the sweep ignores a copied marker; a removal stopped part-way (injected failure) is finished by the next run; a run kept locked is not swept; a started devnet dropped without shutdown is not swept. Each negative sweep case now uses a marker that is valid but for its one difference, with a control that is swept. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
MaidSafe-Hermes
left a comment
There was a problem hiding this comment.
Code Review Summary
Verdict: Changes requested 🔴 (3 blocking data-safety/lifecycle findings; 1 warning)
PR: #263 — fix(ant-devnet): stop cleanly on SIGTERM and use a per-run data dir by default
Reviewed head: 3e3b32da2af9e5c95939314c304e7f2995c68ab5 against 02c8682466df5c0c6723f32c46223a572f020ed4
Files changed: 8 (+1,845 / -26)
Review coverage: Scope A (src/devnet/run_dir.rs): 2/2 seats reviewed (sol-1 / gpt-6.1-sol xhigh; glm-1 / z-ai/glm-5.3). They disagreed: sol-1 requested changes and glm-1 approved. I confirmed the live-lock replacement path below. Scope B: 1/2 seats reviewed; sol-1 returned a substantive review. glm-1 timed out, and its single narrowed retry ended with ConnectionResetError. No team consensus.
🔴 Critical
-
src/devnet/run_dir.rs:326-345— sweeping can remove a live run after its lock file is replaced.finished_runverifies the marker against the run-directory inode, then opensLOCK_FILEby name and accepts any free lock. It does not verify that this is the lock file held by the active run. On Unix, renaming the locked file and creating a new file at that name lets the sweeper acquire the replacement while the original lock remains held; the sweeper can then delete the active run's data. I reproduced the lock behavior with RustFile::try_lock. Record the lock-file identity and verify the opened handle before treating the run as finished; add a regression test that replaces the lock path during a live run. -
src/devnet/run_dir.rs:113-160and416-425— creation can claim or delete a replacement directory. The code creates a name, then opens it in a separate operation. If another process can change the parent namespace between those calls,open_runmay open a different directory. Ifclaimthen fails (for example, because that directory already has a lock file), the error path recursively removes every entry without proving ownership or holding that directory's lock. In a shared writable parent this can delete pre-existing contents. Make the creation namespace private or otherwise prove directory identity, and never recursively clean a directory after an unverified claim failure. Add a deterministic replacement test. -
src/devnet.rs:578-617— shutdown can remove data while writers remain active.protocol_task.abort()stops the event dispatcher, but itstokio::spawnrequest handlers are detached and can continue throughstorage.put. Shutdown does not track or await them. It also logsp2p.shutdown()failures and removes the run directory anyway. A related startup path starts P2P before a fallible WebRTC listener bind, but does not add the node toself.nodesuntil later (src/devnet.rs:1091-1154, 1195); if that bind fails, the subsequent shutdown cannot stop that node before cleanup. Track and drain request tasks, stop partially started nodes on every error path, and retain the run lock/data unless all writers have stopped. Add delayed-PUT and occupied-WebRTC-port regression tests.
⚠️ Warning
src/devnet/run_dir.rs:524-541— Unix cleanup descends through nested mount points. The root run directory is checked against its parent device, but recursive deletion does not check mount identity for descendants. A mount or bind mount under a run directory can have its contents removed before removing the mount point fails. Refuse to cross mount boundaries with a platform-appropriate check, or explicitly define and test this cleanup behavior.
Verification
git diff --checkpassed. The isolated Rust lock probe confirmed that a replacement lock can be acquired while the original lock remains held.- Current-head CI passes for Linux, macOS and Windows tests/builds, Clippy, format, Rust 1.95 MSRV, docs, storage filesystems and WebRTC Direct.
Security Auditstill fails oncrypto-bigint 0.5.5/RUSTSEC-2026-0387. That version is also in the base lockfile; the lockfile change here adds thewinapi-utildependency edge. This is a separate security gate, not a code-review finding.- I did not run the Cargo test suite locally at this head.
No merge action taken.
…t (review round 4) Hermes round 4 on #263: - The sweep opened the lock file by name and took any free lock, so a live run whose lock file was moved aside and replaced could be swept. The marker now records the lock file's file number as well, and the sweep takes the lock only on that very file. - Creating the run directory and opening it are two steps, so something else could put a directory of its own in place of the new one in between. The run then claimed it or, if claiming failed, emptied it. A run now claims what it opened only if it is empty, and a failed claim removes just the files it created, then the directory only if that leaves it empty. - Shutdown aborted the routing loop but not the request handlers it had started, and removed the run directory even when a node's P2P shutdown failed. As a node does, it now gives the handlers, and then the storage writes they started, a few seconds to finish. If anything is still writing, or a P2P shutdown fails, it leaves the directory locked until the process exits, for the next run to remove. A node whose startup fails once its P2P node runs, its WebRTC Direct port taken for one, is now registered before that point, so shutdown stops it. - Deletion on Unix no longer crosses into another mount, told by device and, on Linux, by mount id, which catches a bind mount too. It stops there, leaving the mount, what is in it, and the marker. New tests: a live run whose lock file is replaced is not swept; a marker naming another lock file is not swept; a directory put in place of a new run directory before it is opened is left alone, with or without a lock file in it; deletion stops at a mount point, and a directory on another mount is told apart; a PUT held in the store at shutdown keeps the run directory, locked; a node whose WebRTC Direct port is taken is still stopped by shutdown. A correction to 3e3b32d's message: the marker does not record the directory's device, only its file number. The device is compared with the parent's at the time. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
MaidSafe-Hermes
left a comment
There was a problem hiding this comment.
Code Review Summary
Verdict: Changes Requested 🔴 (3 data-safety/lifecycle findings; 2 test-harness safety findings)
PR: #263 — fix(ant-devnet): stop cleanly on SIGTERM and use a per-run data dir by default
Reviewed head: 58965c4e4a8f7365783c46d01a2b87c46b94f65a
Base: 02c8682466df5c0c6723f32c46223a572f020ed4
Files changed: 8 (+2,290 / -45)
Review coverage: 2/2 seats reviewed in each of 3 complete scopes (run-directory production, run-directory tests, and all other changes): sol-1 (gpt-6.1-sol, xhigh) and glm-1 (z-ai/glm-5.3). Six substantive opinions. The seats raised different findings; there was no consensus. I checked the material objections against the current source and tests.
🔴 Critical
-
src/devnet/run_dir.rs:374-390, 613-655— cleanup can still cross a same-filesystem bind mount and delete data outside the run.finished_runcompares only the candidate and parent devices. For descendants,mount_ofreturnsid: Nonewhen Linuxstatxis unavailable or returnsEPERM; two same-device bind mounts then compare equal, soremove_entrydescends into them. The candidate run directory itself is also not checked against the parent's mount:remove_all_buttreats that candidate mount as its root boundary and can delete its backing directory's contents. Compare the candidate mount with the held parent before cleanup, and fail closed or use a reliable fallback when mount identity is unavailable. Add tests for a same-device nested bind mount and a candidate-root mount. -
src/devnet/run_dir.rs:139-181— creation can claim an empty replacement directory.is_empty()does not prove that the opened directory is the onecreate_runmade. Another process can move the new directory and place an empty directory at its name beforeopen_run; this passes the check, receives this run's marker and lock, and is later recursively removed. The regression test at1146-1178covers only replacements containing a sentinel. Prove ownership with a race-safe creation scheme or a private parent, and add an empty-replacement test that inserts a sentinel after the claim and verifies it survives.
⚠️ Warnings
-
src/devnet/run_dir.rs:185-198—RunDir::path()keeps the caller's symlink/junction alias. The run's internal operations use the held directory, butpath()returnsparent.path.join(name). If an ancestor link is retargeted, callers such asDevnetthat write through this path are redirected while cleanup remains anchored to the original directory. Store and return a canonical stable path, or use handle-relative writes; extend the retarget test to check the path used for node data, not onlyRunDir::remove(). -
tests/devnet_lifecycle.rs:36-57, 87-91— the test can signal an unrelated process group.wait_for_exit()callsChild::try_wait(), which reaps the process.Droplater unconditionally sendsSIGKILLto the saved numeric process-group ID. If that group has become empty and the ID is reused beforeDrop, the signal can hit an unrelated group. Keep the group leader unreaped until group cleanup, or otherwise make cleanup safe against ID reuse. -
src/devnet/run_dir.rs:1425-1438— junction paths are passed throughcmd /Cwithout cmd-safe quoting.Command::argapplies Windows argument quoting, notcmd.exemetacharacter escaping. A temporary path containing shell syntax such as&can change the command parsed bycmd. Create junctions through a native API/helper, or safely construct and quote the complete command; add a metacharacter-path test.
Verification
git diff --checkpassed; the local checkout matches the reviewed head and is clean.- GitHub build, formatting, Clippy, documentation, MSRV, storage-filesystem, and WebRTC checks passed. The Linux and macOS
ant-devnet stop signals and run directoriessteps passed; the Windows step is skipped by the workflow. The Linux/macOS/Windows test jobs were still running their end-to-end stage at review time. Security Auditfailed on the inheritedcrypto-bigint 0.5.5advisory. As requested, this is not an approval blocker; it remains a separate merge blocker.- I did not run the Cargo test suite locally at this head.
No merge action taken.
…th (review round 5) Hermes round 5 on #263: - A bind mount on the same filesystem is told apart only by its mount id. Where Linux gives none (before 5.8, or in a sandbox that refuses statx), deletion fell back to the device, which cannot. It now deletes nothing there. The sweep also never asked whether a run directory was a mount itself, and now leaves one that is not on the parent's mount. - An empty directory put in place of a new run directory before it was opened was claimed. On Unix a run now claims a directory only if its own user owns it, so another user cannot do that. A process running as the same user still could, but it could as well delete whatever the run keeps: no check can hold that line. On Windows, which has no owner check without unsafe code, the parent's permissions decide who could. - RunDir::path kept any link in the parent's path, so retargeting one redirected what the nodes write. It now has the links resolved, on Unix as on Windows (there without the \\?\ prefix where the path means the same without it). - The lifecycle test killed ant-devnet's process group by an id that could be reused once ant-devnet had been reaped. The group now has a leader of its own that does nothing, reaped only after the group has been killed. - The junction test helper passed paths through cmd's parser. They now reach it through the environment and are expanded inside quotes, and the test's paths carry cmd's metacharacters. New tests with real mounts and another user's directory (Linux, through sudo, in a new CI step): neither a tmpfs nor a bind mount on the same filesystem inside a run directory is crossed; a bind mount of a finished run's directory, wearing its name in the parent, is not swept; an empty directory another user owns is not claimed. The retargeting tests also check what is written through RunDir::path. rustix gains its `process` feature, for geteuid. No crate is added. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
MaidSafe-Hermes
left a comment
There was a problem hiding this comment.
Code Review Summary
Verdict: Changes Requested 🔴
PR: #263 — fix(ant-devnet): stop cleanly on SIGTERM and use a per-run data dir by default
Reviewed head: 94231055f17aecdc69648982108f68b763c90eab
Base: 02c8682466df5c0c6723f32c46223a572f020ed4
Files changed: 8
I reviewed the complete base-to-head diff in three scopes. Both seats completed all three scopes. Their opinions differed; I checked the material findings against the current source. I also reproduced the path-redirection behavior described below.
🔴 Changes required
-
src/devnet/run_dir.rs:211-215, 502-521;src/devnet.rs:523-529— canonicalizing the parent once does not make the path stable.Devnet::newstoresRunDir::path()as the node data path. If another user can rename a canonical ancestor of the configured parent, they can replace it with a symlink to a different tree after creation. Later path-based node writes then land in that tree, while the held directory descriptor still refers to the original run. The ownership check on the run directory does not protect its ancestors. I reproduced a write through the saved canonical path landing in a replacement tree after its ancestor was moved and replaced by a symlink. Reject parents with untrusted, renameable ancestry or keep writes anchored to the held directory; add a regression that replaces a canonical ancestor after run creation and checks that writes cannot be redirected. -
src/devnet/run_dir.rs:321-335, 364-366— sweeping can erase a run directory after it has been moved out of its name.finished_runpasses its open directory handle todelete;deleterecursively removes the contents before checking that the parent entry still names that directory. Thesame_dircheck at line 334 protects only the final empty-directory removal. A writer to a shared parent can move the eligible directory between the check and deletion, and the sweep will still empty it through the held handle. Make the parent namespace safe from untrusted renames, or make the transition into destructive cleanup race-safe; add a deterministic move-after-eligibility test.
Verification
git diff --checkpassed.- All reported build and test checks passed on Linux, macOS, and Windows, including the test and end-to-end stages.
Security Auditfailed. Per the request, I did not treat that as an approval blocker; it remains a merge gate and is not the reason for this verdict.
No merge action taken.
|
|
||
| /// The run directory, with any link in the parent's path resolved, so | ||
| /// that retargeting one does not redirect what is written through it. | ||
| pub fn path(&self) -> &Path { |
There was a problem hiding this comment.
Canonicalizing the parent removes links only at open time; it cannot stop another user with rename rights in a canonical ancestor from replacing that ancestor later. Devnet::new persists this path for node writes. I reproduced writes following the replacement into the new tree. Please reject untrusted/renameable ancestry or keep writes descriptor-relative, and test an ancestor swap after run creation.
| /// free lock make the rest a finished run that the next sweep removes. After | ||
| /// that, only an empty directory and an empty lock file can be left. | ||
| fn delete(parent: &Parent, name: &str, run: Run, lock: File) -> io::Result<()> { | ||
| run.remove_all_but(&[MARKER_FILE, LOCK_FILE])?; |
There was a problem hiding this comment.
The sweep calls delete without checking same_dir before this recursive removal; the only same_dir check is after contents are removed, at line 334. An actor able to change the parent namespace can move an eligible directory after finished_run checks it, while this held handle still empties it outside its run name. Secure the parent namespace or make the destructive transition race-safe, and add a move-after-eligibility regression.
Hermes round 6 on #263: - RunDir::path has the links in the parent's path resolved once, but that does not keep it still: another user who can write to a directory above the run directory can swap it for a link to another tree, and what the nodes write through the path follows. On Unix a run now refuses to start unless the parent, and every directory above it, belongs to the user or root and is writable by no one else, or is sticky, as /tmp is, which lets no one rename what they do not own. On Linux a directory its group can write to passes if the group is the user's own, as with Ubuntu's user private groups, and no ACL lets anyone else in; on macOS, where every user shares a group, it does not. On Windows the held handles already keep every directory above the run directory from being renamed; a test now shows it. - The sweep checked that a run directory's name still referred to the directory it had open only before removing the then empty directory, so one moved out of its name after it was found finished was still emptied where it went. Deletion now checks first. Once that has passed, only the user or root can still move it: no one else can change the parent. The sweep also leaves a run directory another user owns: in a sticky parent they could still move it, and what is in it is theirs. New tests: parents below a directory anyone can write to, or with an ACL, are refused, and sticky ones, or ones the user's own group can write to, are not; a finished run moved once the sweep has found it is not emptied. Through sudo (Linux, in the CI step): another user can move what is in a directory anyone can write to, which a run refuses, but not what is in a sticky one, which a run starts in, and writes there land in the run directory; parents below another user's directory, or one another group can write to, are refused; another user's finished run is not swept. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
main's CI consolidation (V2-1811) replaced the per-target test steps with one loop over every integration target. The lifecycle test now runs in that loop, skipped on Windows, where it compiles to nothing. The run directory sudo tests keep a Linux step of their own. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
MaidSafe-Hermes
left a comment
There was a problem hiding this comment.
Code Review Summary
Verdict: Changes requested (1 confirmed data-safety blocker)
PR: #263 — fix(ant-devnet): stop cleanly on SIGTERM and use a per-run data dir by default
Reviewed head: 5263208259b4b7159a1f44d0a1faed32f8390a79
Base: e61446b896b6cbf50bbd7aa39af385ee11dfd16f
Review coverage: 2/2 seats reviewed the Unix parent-permission guard (sol-1, gpt-6.1-sol xhigh; glm-1, z-ai/glm-5.3). The GLM seat first failed with ConnectionResetError; its one narrowed retry returned a substantive CHANGES_REQUIRED review. Both seats identified the shared-effective-GID gap. This is agreement between two model families, not team consensus.
Changes required
src/devnet/run_dir.rs:736-744— group-writable parents are accepted based only on the process's effective GID. On Linux,group_writersreturnsOk(None)whenst_gid == getegid()and the directory has no POSIX ACL. It does not establish that the group has no other members. On a system where another user shares that GID, that user can write to the accepted group-writable parent and rename or replace the run-directory entry. Later node writes throughRunDir::path()can then be redirected, defeating the new data-protection guarantee. The added acceptance test explicitly blesses this path. Reject group-writable ancestors unless group exclusivity is verified reliably; add a regression with another user sharing the accepted GID and change the acceptance test.
Verification
git diff --checkpassed.- Current GitHub CI checks passed, including Linux, macOS, Windows, MSRV and CI Gate. Security Audit failed and remains a separate merge gate; it is not the reason for this code verdict.
- The local
cargo test --locked --lib devnet::run_dir::tests -- --test-threads=1did not complete within the tool timeout, so I do not count it as a local test result.
No merge action taken.
MaidSafe-Hermes
left a comment
There was a problem hiding this comment.
Code Review Summary
Verdict: Changes requested (1 confirmed macOS data-safety blocker)
PR: #263 — fix(ant-devnet): stop cleanly on SIGTERM and use a per-run data dir by default
Reviewed head: 5263208259b4b7159a1f44d0a1faed32f8390a79
Base: e61446b896b6cbf50bbd7aa39af385ee11dfd16f
Coverage: 2/2 seats reviewed the Unix parent-permission guard (sol-1, gpt-6.1-sol xhigh; glm-1, z-ai/glm-5.3). Both identified the macOS ACL limitation as an unverified risk; I verified the relevant ACL permissions against Apple’s File System Programming Guide. The Linux own-primary-group exception from review 7 is an owner-accepted risk and is not a blocker for this verdict.
Changes required
src/devnet/run_dir.rs:698-748— macOS ACLs can grant another user the ability to replace a run-directory entry, but the parent check inspects only ownership and BSD mode bits. On non-Linux Unix,group_writersdoes not inspect ACLs. macOS ACLs include directory rights such asadd subdirectoryanddelete child, which are separate from BSD mode bits. If an ACL grants those rights on the configured parent or an ancestor,no_one_else_can_changeaccepts it; that user can move or replace a run-directory entry, redirecting later writes throughRunDir::path(). Add a macOS ACL-aware check that rejects ACEs allowing another user to create/remove directory entries, with a regression test; otherwise narrow the guarantee and record explicit owner acceptance of this risk.
Verification
- The exact PR head is unchanged.
git diff --checkpasses. - Current required PR checks (
linear-link,pr-template) and CI Gate pass. Security Audit remains failed, as disclosed; it is not treated as an approval blocker here. - The Linux primary-group exception is excluded from this verdict as requested.
No merge action taken.
MaidSafe-Hermes
left a comment
There was a problem hiding this comment.
Code Review Summary
Verdict: Approved
PR: #263 — fix(ant-devnet): stop cleanly on SIGTERM and use a per-run data dir by default
Reviewed head: 5263208259b4b7159a1f44d0a1faed32f8390a79
Base: e61446b896b6cbf50bbd7aa39af385ee11dfd16f
The author’s 2026-11-04 update explicitly accepts the documented limitations in the PR body’s “Not covered” section, including the Linux own-primary-group and macOS ACL cases raised in reviews 7 and 8. I therefore do not treat those documented risks as blockers. I found no other unresolved blocker outside that accepted scope. The earlier run-directory race findings are addressed by the round-7 changes and their regression coverage.
Review coverage: The two review seats (sol-1, gpt-6.1-sol xhigh; glm-1, z-ai/glm-5.3) reviewed the latest parent-permission guard at this exact head. Earlier review rounds covered the unchanged lifecycle and run-directory implementation. The current source and CI evidence support approval.
Verification
git diff --checkpassed; the local checkout is at the reviewed head and clean.- Current
linear-link,pr-template, and CI Gate checks pass; the Linux, macOS, Windows, MSRV, storage, and release-build jobs pass. - Security Audit remains failed on the disclosed inherited
RUSTSEC-2026-0387issue. Per the PR’s documented check requirements, it is not a required check and does not block this approval.
Approval is not a merge action.
ant-devnetnow stops cleanly on SIGTERM, and by default each run gets a fresh data directory. Fixes #261 and the root cause of WithAutonomi/ant-sdk#73. It also fixes something worse that turned up on the way: a graceful stop of a default devnet deleted the whole ant data root.SIGTERM
ant-devnetwaited only for Ctrl+C. A SIGTERM kept its default action and killed the process outright. That means:SIGTERM comes from
kill, from service managers, fromant dev stop, and fromant dev start's own wait timeout. Interrupted runs piled up untilant dev starthung for good (#73).What changes:
stop_requested().Devnet::cancellation_token()).startreturns at its next safe point: between node spawns, or in its readiness and stabilization waits.shutdownstops them all before it removes the directory.startfuture part-way, as I first did, left an unregistered node writing into the directory being removed. That leaked the directory in about 1 run in 18.The data root
DevnetConfig::default().data_dirwasdefault_root_dir(): the whole ant data root,~/.local/share/anton Linux.cleanup_data_dirdefaults to true, so every graceful stop ranremove_dir_allon it.config::data_dir(). Ctrl+C on a default devnet therefore deleted:nodes/.Run directories
A devnet deletes only a directory it created and marked as its own (new
src/devnet/run_dir.rs):{pid}-{random}directory insideDevnetConfig::data_dir/--data-dir, withmkdir(notcreate_dir_all), so it never adopts something that was already there. It claims what it then opens only if that is empty and, on Unix, owned by the run's own user (geteuid), so a directory another user puts in place of the new one in between is left as it is; a claim that fails removes only the files it created, and then the directory only if it is empty. The default parent is{data root}/devnets(newDEVNETS_SUBDIR).--data-diris now the parent. It, and anything else in it, is never deleted. Before,--data-dir ~/Documentsplus Ctrl+C ranremove_dir_all(~/Documents)..ant-devnet-run.lockfor as long as it lasts, then writes.ant-devnet-run.json(run id, the file numbers of the directory and of the lock file, pid, start time, version,keep), atomically via rename. Both names are exported asLOCK_FILEandMARKER_FILE.--no-cleanupsetskeep), and whose lock is free, taken on the very lock file the marker names: a run's lock stays held under any name its file is moved to, so a fresh file put in its place doesn't count. The OS releases a lock however its process ends, and only a run's own process ever takes it, so a free lock means the run is over; a reused PID can't fool it. Anything without such a marker is never touched. The marker keeps no device number, since btrfs and NFS can change it on remount, which would strand leftovers after a reboot. Downstream tools (ant dev stop) can follow the same rule.statx), which catches a bind mount of a directory on the same filesystem too. Where the kernel can't give one (before Linux 5.8, or a sandbox that refusesstatx), it deletes nothing, so run directories stay there until removed by hand. macOS has no bind mounts, so there the device tells mounts apart. Deletion stops at a mount, leaving it, what's in it, and the marker. (On Windows a mounted folder is a reparse point, whichopen_runrefuses andremove_dir_allremoves without entering.)node.rsdoes on a node's shutdown, the routing loop is aborted and awaited, then the request handlers it started, and then the storage writes they started (ChunkStore::wait_idle), get 5 seconds each to finish. If anything is still writing then, or a node's P2P shutdown fails, the run directory is left, locked until the process exits, and the next run removes it.Devnetdropped withoutshutdown()leaves its nodes running (their tasks hold them), so it now keeps the run directory locked until the process exits; no sweep deletes it under them, and the next run after that removes it, as after a SIGKILL.mkdirat/openat/unlinkat), and deletion walks the tree throughO_NOFOLLOWhandles. Retargeting a symlink above the run directory mid-run can't redirect it; a link inside is removed, never entered.PermissionDenied, naming the directory) unless the parent and every directory above it belong to the user or root and no one else can write to them, or they're sticky, as/tmpis, which lets no one rename what they don't own. Whoever could write to one could swap a directory below it for a link, redirecting the nodes' writes, or move a run directory while it's being deleted. On Linux, a directory its group can write to passes if the group is the user's own primary group and it has no ACL (an ACL shows in the group bits only as its mask). That keeps the default parent working on Ubuntu, whose umask makes~/.local/sharegroup-writable under user private groups. On macOS, where every user's group isstaff, it doesn't pass. The default parent and temporary directories under/tmppass, as do macOS's under/var/folders(the macOS CI job's tests run in them).RunDir::path(), and with itconfig().data_dirand the manifest'sdata_dir, has the links in the parent's path resolved, so retargeting one mid-run doesn't redirect what the nodes write either. On Windows it's given without the\\?\prefix where the path means the same without it. On macOS this shows/var/...as/private/var/....*atcalls in the standard library. There, links in the parent's path are resolved once (canonicalize, re-checked once the parent is held), and the parent and the run directory are held open withoutFILE_SHARE_DELETE. While they are, Windows refuses to rename or remove them or any directory above them, so the paths built on them keep naming the same directories;std::fs::remove_dir_alldoesn't follow links inside the tree. Identity is the volume serial number and file index, throughwinapi-util.Devnet::config().data_dirstill names the run directory afternew(), so the manifest'sdata_dirand ant-core'sLocalDevnetsee what they did before.Not covered
Who the parent check trusts: root, and the user's own processes. On Linux it also trusts the members of the user's primary group, for a directory that group can write to: that's just the user under user private groups, but not where users share a primary group. It reads owners, permission bits and, on Linux, ACLs. It doesn't read macOS ACLs, which no call reaches without unsafe code. Only the user or root can move a run directory while the sweep is deleting it, after its name has been checked.
Accepted (Nic, 2026-11-04, after reviews 7 and 8): every limitation in this section is a known, accepted risk. This code runs only for local devnets, never for
ant-node, and it protects throwaway test data on a developer's machine. None of these limitations lets another account do anything on a normal single-user dev machine; each needs an unusual multi-user setup, or a process already running as the developer.%APPDATA%, is private to the user.A process running as the devnet's own user can put an empty directory of its own in place of a new run directory, between its creation and its opening; the run then claims it, and later deletes what that process put in it. No check can stop that, since Unix has no call that creates a directory and opens it in one step, and such a process could as well delete whatever the run keeps. Another user can't, on Unix, since the run refuses a directory it doesn't own. On Windows, which has no owner check without unsafe code, the parent's permissions decide who could.
If ant-devnet is SIGKILLed, its run directory stays until the next run sweeps it, and Anvil is still orphaned, since no handler can run. The fix would be
PR_SET_PDEATHSIGon the Anvil child, but Anvil is spawned inside evmlib/alloy, so that's a follow-up there.Linear issue
Closes V2-1777
Also closes #261 (the GitHub issue). Root cause of WithAutonomi/ant-sdk#73.
Risk tier
Proposed T0: only the devnet tooling (
ant-devnet,ant_node::devnet) changes. There's no change to node behavior, the wire protocol, storage format, payments or upgrades.Compatibility
{parent}/{pid}-{random}, parent{data root}/devnetsby default. Tools that looked for devnet nodes under{data root}/nodesor{--data-dir}/nodesshould readdata_dirfrom the manifest.--data-dirnow names the directory the run directory is created in. On Unix it must be one that only the user and root can change, and so must every directory above it: ant-devnet refuses to start otherwise, where before it started in, say, a world-writable shared directory. The only caller I found, ant-client'sbrowser-tests/start-devnet.mjs, passes a freshmkdtempdirectory and removes it itself, so it is unaffected;ant dev startand ant-core use the default.Devnet::cancellation_token(),DEVNETS_SUBDIR,LOCK_FILE,MARKER_FILE.DevnetConfig::data_diris now the parent (default{data root}/devnets); afterDevnet::new,config().data_diris the run directory, the waybase_port: 0is resolved there too. ADevnetdropped withoutshutdown()keeps its run directory, and its lock, until the process exits (before, dropping deleted nothing either).Semver impact
Proposed feature; flip to breaking if you count
--data-dir's new meaning (no known caller is affected).Test evidence
Round 7 (Linux dev box, Ubuntu 24.04, umask 0002 with user private groups; and Windows 11 for the Windows code):
cargo +stable clippy --all-targets --all-features -- -D warningsclean.cargo fmt --checkandcargo docwith-D warningsclean.cargo test --lib --features test-utils devnet::passes, 31 tests plus 6 ignored, which need sudo. New:0777) is refused and gets nothing in it; on macOS, also one below a group-writable directory;1777) starts; on Linux, so does one below a directory the user's own group can write to (0775);system.posix_acl_accessvalue) is refused;nobody: in a root-owned0777directory,nobodycan move a directory the user made, and a run refuses to start below it. In a root-owned1777one, a run starts,nobodycan't move the directory it's below, and a write throughRunDir::path()lands in the run directory;nobodyowns, or below one group 65534 can write to, is refused;nobodyowns, and that the user could empty (a+rwX), isn't swept.run_dir.rson its own on Windows 11: all 16 tests pass, clippy clean. The run directory, the parent, the directory above that, and the temporary directory above all of them can't be renamed while the run lasts./tmpfails);removenow relies on that check too.tests/devnet_lifecycle.rspasses (3 tests), now with--features test-utils, as main's loop runs it./var/foldersand passes. On Windows the loop skipsdevnet_lifecycle, and the sudo step on Linux reports 6 passed.Round 6:
cargo +stable clippy --all-targets --all-features -- -D warningsclean.cargo fmt --checkclean.cargo test --lib --features test-utils devnet::passes, 27 tests plus 3 ignored, which need sudo:cargo test ... with_sudo_ -- --ignored, with the same ran-no-tests guard as the other harness steps): a tmpfs, and a bind mount of a directory on the same filesystem, inside a run directory, are not crossed, and what they show survives; a bind mount of a finished run's directory, kept elsewhere, put in the parent under its name, matches in everything but its mount and is not swept;0777, so the run could write in it), put in place of a new run directory before it is opened, is not claimed.RunDir::path()after the retarget, and check that the file lands in the original directory, not the decoy. The junction test's paths carry&,%...%,^,(,),!,;and=.run_dir.rson its own on Windows 11: all 15 tests pass, clippy clean.path()keeping the caller's links; Windowspath()keeping the junction; and the junction helper's oldcmd /Ccall (the metacharacter paths break it).tests/devnet_lifecycle.rspasses (3 tests).Round 5:
cargo +stable clippy --all-targets --all-features -- -D warningsclean.cargo fmt --checkandcargo docwith-D warningsclean.cargo test --lib devnet::passes, 27 tests. New:/procon Linux,/devon macOS) from one on the temp directory's;ChunkStore::test_put_gate) at shutdown keeps the run directory, locked, so the next devnet leaves it too;run_dir.rsbuilt and tested on its own on Windows 11: all 15 tests pass, clippy clean.shutdownfail; it takes the same path as the held PUT.tests/devnet_lifecycle.rspasses (3 tests).Round 4:
cargo +stable clippy --all-targets --all-features -- -D warningsclean. On 1.95, nothing in the changed files (the same 6 existing lints elsewhere).cargo fmt --checkandcargo docwith-D warningsclean.cargo test --lib devnet::passes, 21 tests. New:shutdown()is not swept by the next one;run_dir.rsbuilt and tested on its own on Windows 11 (rustc 1.97, same clippy lints as the crate): all 13 tests pass, clippy clean. Windows-only tests: the run directory and the parent can't be moved while the run lasts, and retargeting a junction above the run mid-run doesn't redirect removal, even onto a decoy carrying the run's marker.Devnet::dropreleasing it; and on Windows, not resolving the parent's links.tests/devnet_lifecycle.rspasses (3 tests).Rounds 1–3:
Run on a Linux dev box (Ubuntu 24.04, rustc 1.95):
cargo fmt --all -- --checkandcargo doc --all-features --no-depsboth clean.cargo clippy --all-targets --all-featuresraises nothing in the changed files. That box's 1.95 toolchain flags 6 existing lints insrc/replication/pointer.rsandsrc/storage/chunk_store.rs, which this PR doesn't touch; CI's toolchain passes them on main.cargo test --lib devnet::passes, 16 tests, among them:--no-cleanupdirectories survive later sweeps.keep, and skipping the marker check on removal each make exactly the matching test fail.tests/devnet_lifecycle.rs: unix only, and a new CI step with the same ran-no-tests guard as the other harness steps. It runs the real binary withHOMEandXDG_DATA_HOMEin a temp dir:--data-dirholding a sentinel): exit 0, run directory removed, the sentinel and--data-dirkept, no manifest written;pkill -Pbefore SIGKILL).tests/webrtc_direct_devnet.rs(all features), which setsdata_dir, still passes.New dependency
rustix (already a dependency) gains its
processfeature, forgeteuidandgetegid. No crate is added.winapi-util0.1, Windows only, for a directory handle's volume serial number and file index. It adds no crate to the build: it was already inCargo.lock(through walkdir, build and dev only), and what it uses, windows-sys 0.48, already ships in the Windows build throughdirectories.Cargo.lockchanges by one line (ant-node's own dependency list).ADR
n/a
Mitigation / rollback
Revert. No data migrates: run directories are throwaway, and the next run (or deleting
devnets/by hand) clears any that are left.🤖 Generated with Claude Code