mirror of
https://github.com/kvcache-ai/AgentENV.git
synced 2026-11-05 01:17:36 +00:00
[GH-ISSUE #346] bug(plain-snapshotter): corrupted config.json silently wipes all registrations on RegisterRootfs (nil-map panic on null); snap-* temp dirs leak; unknown parent silently yields an empty rootfs #87
Labels
No labels
bug
documentation
enhancement
enhancement
pull-request
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
kvcache-ai/AgentENV#87
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Originally created by @tm-worker on GitHub (Oct 9, 2026).
Original GitHub issue: https://github.com/kvcache-ai/AgentENV/issues/346
Originally assigned to: @guozy18 on GitHub.
Affected component
containerd-plain-snapshotter(Go proxy snapshotter,containerd-plain-snapshotter/)AgentENV version
main@4968423(2026-10-12) — first verified on34cdc80(2026-10-09); re-confirmed on4968423viagit diffthat the wholecontainerd-plain-snapshotter/module is byte-identical between the two revisions, so the findings carry over unchanged. All findings below were exercised on34cdc80.Host environment
registerpath was driven with a shimdockeron PATH (docker load→ exit 0,docker image inspect --format '{{json .RootFS}}'→ fixed RootFS JSON), so the tested code path (NewSnapshotter→getConfig→RegisterRootfs) is the real binarydockerbinary on PATH (a test double emulating the two docker CLI calls) and a ~60-line gRPC probe client (using the module's owncontainerd/api+grpcdependencies) placed in a scratch copy of the module to drive T3/T4. No file insidecontainerd-plain-snapshotter/was changed.Operation and configuration
Default configuration of the plain snapshotter:
config.jsonformat is documented incontainerd-plain-snapshotter/README.mdas{ "<chainID>": "<rootfs path>" }, maintained automatically byregister.Steps to reproduce
T1 — corrupted config silently wipes all registrations:
config.json:{"sha256:1111...": "/path/rootfsA"}printf '[]' > config.json./plain-snapshotter register -root <root> imgB /path/rootfsBcat config.json→ now contains ONLY the new entry; the old registration is goneT2 —
nullconfig panics:printf 'null' > config.json./plain-snapshotter register -root <root> imgC /path/rootfsCpanic: assignment to entry in nil map(snapshotter.go:68)T3 —
snap-*temp dirs leak:./plain-snapshotter -address /tmp/ps.sock -root <root>Prepare(key_i, parent="")thenRemove(key_i)ls <root>→ 5snap-*directories remain;Removeonly deletes the in-memory map entry (snapshotter.go:269-275), and there is no GC of<root>anywhere in the moduleT4 — unknown parent silently yields an empty rootfs:
Prepare(key="moby/8/sha256:UNKNOWN", parent="sha256:NOTREGISTERED...")snapshotter.go:203-222falls through toos.MkdirTemp)Expected behavior
getConfigshould fail loudly on malformed JSON (theregistercommand should exit with an error naming the config file) instead of treating the file as empty and rewriting it.Removeshould delete thesnap-*directory the snapshotter itself created (it must not touch external rootfs dirs referenced byconfig.json); a startup sweep for orphanedsnap-*dirs would also fix the restart case.Prepare/Viewwith a non-empty parent that resolves to nothing should returnerrdefs.ErrNotFound, per the containerd snapshotter contract, instead of an empty-rootfs bind mount.Actual behavior
json.Unmarshalerror is discarded (snapshotter.go:59);getConfigreturns an empty map andRegisterRootfsatomically rewritesconfig.jsonwith only the new entry — every existing chainID→rootfs mapping is silently lost. Subsequentdocker runof previously registered images fails inMounts(snapshotter.go:169-174) with NotFound, with no hint the config was wiped.nullsets the map to nil;config[chainID] = rootfsPathatsnapshotter.go:68panics:assignment to entry in nil map.Preparethat misses both config and parent createsos.MkdirTemp(s.root, "snap-")(snapshotter.go:216-222);Removedeletes only the map entry (snapshotter.go:273). After a snapshotter restart (in-memory map is intentionally cleared), everysnap-*dir from the previous run is orphaned permanently;<root>grows unbounded.config-*.tmpfiles also leak ifos.Renamefails (snapshotter.go:87).Preparewith an unknown parent returns success and an empty rootfs. Combined with T1/T3 this masks root causes: a container started from an unregistered (or wiped) image boots into an empty filesystem and fails later with confusing errors likeexec: "/bin/sh": stat ...: no such file or directory.Logs and diagnostics
T1 (register over
[]config):T2 (register over
nullconfig):T3 (5 prepare/remove cycles):
T4 (unknown parent):
Reproduction frequency
Always — all four are deterministic (no daemon, no timing involved).
Regression information
Not a regression: the module was introduced recently (
5353143"feat(containerd): add plain snapshotter for running containers from a rootfs directory") and the behaviors have existed since introduction.Additional context
Lower-severity observations (code-level, not separately reproduced):
Commit(snapshotter.go:241-267) does not check name collisions and silently overwrites an existing committed snapshot (containerd expectsErrAlreadyExists).Usage(snapshotter.go:156-159) always returns{0,0}, even for unknown keys.Stat/WalkfabricateCreated/Updated: time.Now()for config entries, so timestamps drift.Mounts(snapshotter.go:167-172) does not verify the registered rootfs dir still exists.Suggested fix directions:
getConfig: return(map, error); propagate JSON errors with the file path as context.RegisterRootfs: add a file lock for cross-process safety (the mutex is in-process only).Remove: deletesnap-*dirs the snapshotter created; add a startup sweep for orphaned ones.createSnapshot: unknown parent →errdefs.ErrNotFound.Happy to open a PR covering T1-T4 if maintainers agree with the directions.
Pre-submission checklist
@Chaoqi31 commented on GitHub (Oct 14, 2026):
Since this issue is assigned, I won't open a PR. Two notes in case they help.
T3 is riskier than it looks.
registeraccepts any directory, so a registered rootfs can be asnap-*dir or sit inside one, and deleting the dir inRemovewould take that rootfs with it. A startup sweep adds a second hazard. After a restart the in-memory key map is empty, but containers can still be running on those dirs through their bind mounts. A safe fix probably needs the key-to-dir map persisted, plus a check that no registered rootfs lives under a dir before deleting it. In the supported flow each leaked dir only holds the placeholder layer fromdocker load, so the leak itself is small.T1, T2, T4 and the lock. There is a tested patch in one commit on 4968423, limited to
containerd-plain-snapshotter/:getConfigreturns an error naming the file for read errors, bad JSON, and non-object JSON. A missing file is still an empty config.registerreads the config beforedocker load, so a bad config exits 1 with no image loaded and the file untouched. The server refuses to start on it.RegisterRootfsholds anflockon<root>/config.lockaround the read-modify-rename, and removes its temp file if the rename fails.Prepare/Viewwith a parent that is neither registered nor known in memory returnErrNotFound.Checked with
go test -race -count=20 ./..., a test where 32 snapshotters register at once (without the lock, 1 to 4 of the 32 entries survive), and an end-to-end run of binaries built from main and from the patch, through a shimdockerand containerd'ssnapshots/proxyclient. Use any of it if it's useful.The patch and this note were prepared with Claude Code.
@guozy18 commented on GitHub (Oct 15, 2026):
@tm-worker Thanks for the detailed report and the proposed patch. We've verified and reproduced the behavior you described. However, we'd like to share some context on how plain-snapshotter is scoped and used in AgentENV.
In our current environment:
Given this lifecycle and initialization flow, our deployment is not exposed to the concurrent-writer lost-update condition. For this reason, we do not plan to adopt file locking at present to keep the implementation minimal.
That said, the issue you identified is completely valid. If we eventually introduce parallel image registration within a single sandbox, we will definitely revisit file locking. We also recognize that standalone setups or deployments with different lifecycle assumptions may have different requirements.
@tm-worker commented on GitHub (Oct 16, 2026):
Thanks for verifying and for the deployment context — the per-sandbox isolated
instance explains the sequential assumption. Agreed the lock can wait until
parallel registration is actually needed.