mirror of
https://github.com/kvcache-ai/AgentENV.git
synced 2026-11-05 01:17:36 +00:00
[GH-ISSUE #138] fix(p2p): sandbox veth churn kills the iroh endpoint ~1.3s after startup, disabling P2P for the process lifetime #46
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#46
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 @dushulin on GitHub (Aug 7, 2026).
Original GitHub issue: https://github.com/kvcache-ai/AgentENV/issues/138
Summary
With
[p2p].enabled = true, the iroh endpoint and theiroh-blobsstore are torndown ~1.3 s after node startup, before a single artifact is ever served. On an
8-node cluster, 7 of 8 nodes died this way; the 8th survived only by winning a race.
Once dead, nothing ever restarts the transport, so P2P is silently unavailable for
the entire lifetime of the process and every overlaybd read falls back to the origin
registry.
The trigger is AgentENV's own warm-pool priming: the
veth-*interfaces it createsin the runtime pod's network namespace are seen by
netwatchas a major linkchange, which makes iroh rebind its UDP socket — and that rebind is not recoverable
when it fails.
Environment
v0.1.31.0.0-rc.0(also reproduces by inspection on1.0.3)0.19.1(unmodified from crates.io)hostNetworkunset)[p2p] enabled=true, transport="iroh", listen_addr="0.0.0.0:0",[snapshot] p2p_enabled=true,[ublk] download_enable=trueReproduction
Enable P2P with the default
listen_addr = "0.0.0.0:0"and start the node. Thefailing log lines are debug-level only, so an
infolog shows nothing at all —this is why the failure went unnoticed. Use:
Observed on 7 of 8 nodes, marker counts byte-identical on every one of them
(
rebinding=1,Address already in use=3,socket closed=2,closing database=1):After this point the process logs no further iroh activity for its whole lifetime.
Root cause
Three separate defects compose into the failure. Only the first is AgentENV's.
1. Sandbox
vethchurn is reported as a major link change (AgentENV + netwatch)10.12.0.2and10.12.0.4are host-side ends of sandbox veth pairs, inside[network].veth_cidr = 10.12.0.0/16. They appear indirect_addrsin the samemillisecond as
is_major=true, immediately afterfirecracker pool primed warm=2.So AgentENV's own routine warm-pool priming is what knocks out its own P2P transport.
On Linux, netwatch treats every interface as relevant
(
netwatch-0.19.1/src/netmon/linux.rs):Sandbox veths are node-local plumbing and can never carry P2P traffic, so they should
not influence the endpoint's address set or force a rebind. On a busy node these are
created and destroyed constantly, so this is not a one-shot startup problem — it is a
permanent source of endpoint churn.
2.
netwatch's rebind destroys a working socket before it knows it can replace it (upstream)netwatch-0.19.1/src/udp.rs(verified byte-identical to the crates.io release):Two problems:
Any transient bind failure therefore costs the endpoint permanently.
fires in our logs — a single failure is terminal. The next
is_majorlink changewould be needed to try again, and none arrives, because once the warm pool is primed
the interface set stops changing.
The resulting
Closedstate makes every subsequent send/recv returnBrokenPipe: socket closed, whichnoqescalates toI/O error: socket closed, whichin turn closes the
iroh-blobsstore.3. The
EADDRINUSEis a race, and AgentENV never notices the endpoint is goneOne node out of eight ran the identical sequence and its rebind succeeded; its
endpoint is still healthy hours later. So this is a timing race on re-binding a
just-released ephemeral port (the pod's range is 32768–60999), not a structural
conflict.
We ruled out a duplicated/retained fd inside the stack:
netwatch/src/udp.rsexposes no fd-escaping API (noas_raw_fd,into_inner,dup,ManuallyDrop,mem::forget).noq_udp::UdpSockRef<'a>issocket2::SockRef<'a>— a borrow.UdpSocketStatestores no fd.
iroh/src/socket/transports/ip.rs:277only delegates:self.socket.rebind()?.Independently of who wins the port race, AgentENV has no supervision of the P2P
transport: a dead endpoint is never detected, never reported, and never restarted.
A
p2p.enabled = truenode that lost its endpoint is indistinguishable from a healthyone from the outside, and simply serves every read from the origin registry.
Suggested fixes
veth-*prefix out of netwatch's interface interest set (and out of the reportedaddress set), so warm-pool and sandbox churn is invisible to iroh.
if it stays unavailable, fall back to binding port
0. A fresh ephemeral port isstrictly better than a dead endpoint — address changes are exactly what the rebind
path is designed to handle. Worth reporting upstream to
netwatch/iroh.and re-create the endpoint when it dies, so this can never again be a silent
degradation.
Second, independent finding: the overlaybd layer catalog is never populated
Even with a healthy endpoint, the overlaybd P2P path cannot hit. Across all 8 nodes
there has never been a single
overlaybd-layer/v1/sha256:*key in the iroh catalog(the current catalog WAL is 0 bytes on every node; the only keys present anywhere are
snapshot/v1/artifacts/*). Three reasons, all in code:overlaybd-layer/v1/*ispublish_completed_layer()(
storage/overlaybd/src/bk_download.rs:275, invoked at:736and:770), whichruns only after a complete background layer download. Locally complete commits
that need no download are also gated behind the same path.
DownloadConfig::default()(storage/overlaybd/src/config.rs:116-129) setsdelay: 300, delay_extra: 30, inherited verbatim bysrc/setup/deps.rs:286-288via..Default::default(). So no background download even starts for 300–330 s, andany sandbox shorter-lived than that publishes nothing, ever.
src/overlaybd/p2p/facade.rs:32-33), so anempty catalog means a fresh full lookup every 5 s on the block-read critical
path, with
lookup_peershandshaking peers sequentially(
src/p2p/iroh/transport.rs:218-249, carrying// TODO: parallelize lookups to multiple peers.). This is the mechanism behind the measured exec p50 of 28.7 ms(P2P off) vs 2766.7 ms (P2P on, empty catalog).
Suggested fix: publish already-complete local layers immediately instead of only as a
side effect of a delayed background download, and avoid broad peer polling when the
scheduler artifact index returns no hint. (Lookup fan-out itself is tracked in #95.)
@dushulin commented on GitHub (Aug 7, 2026):
Correction to the "empty layer catalog" part of the report above — I said the overlaybd layer catalog is never populated. That is too strong, and the precise shape of the failure matters for the fix.
overlaybd-layer/v1/*has two key families, and only one of them is broken:uuid/<uuid>src/snapshot/p2p.rs(snapshot-produced layers)sha256:<digest>bk_download::publish_completed_layer(registry-downloaded layers)Measured 2026-08-07 on the two nodes that hold a warm image cache, by extracting keys directly from
{p2p store}/iroh/catalog.db/*.sst:The digest family is the one a cold overlaybd read looks up, so 0–1 entries against 139–265 complete local commits is why reads still go to the origin registry even on a node that already has the bytes.
Cause: the only publisher of the digest family is
publish_completed_layer, reached only after a background layer download that first waits outDownloadConfig::default().delay(300 s + 30 s extra). A layer that is already complete on disk never takes that path, so it is never advertised — and a sandbox shorter-lived than the delay advertises nothing at all.Two practical consequences:
overlaybd-layer/v1/sha256:*key count versus the node's*.commitcount.delaydoes not fix it. The publish needs to be decoupled from the download entirely: a layer that is already complete and checksum-valid locally should be advertised immediately, without any registry traffic and without consuming a download attempt.@dushulin commented on GitHub (Aug 7, 2026):
Third defect, and this one is the reason P2P still served nothing after the first two were fixed: the consumer and the publisher key the same layer differently whenever the registry redirects blob GETs to an object store.
What happens
registryfs_v2::fetch_range_responseresolves the blob URL first, then hands the accelerator the post-redirect URL:CanonicalBlobIdentity::from_origin_urlrecovers the digest by looking forsha256:<64hex>in the URL path. An object-store URL has no digest in it, so the key falls back to:Publishing, meanwhile, always uses the digest family (
overlaybd-layer/v1/sha256:<digest>). So every foreground lookup misses by construction, and every read silently falls back to the origin registry.This affects any registry that 307s blob GETs to object storage — Harbor+ceph, ECR+S3, GCR, ACR.
UrlMode::SelfAuthregistries are unaffected, which is why it does not show up in the unit tests.Measured
Harbor 2.3.1 fronting ceph, 8 nodes, P2P on, one cold node with its
remote-blocksblock cache cleared so reads had to leave the machine:which looks like a success until you read the lookup keys in the log:
10 distinct layers × 7 peers = 70 catalog connections, all on
url/keys, all missing against the peers'sha256:entries.Two things that make this hard to see
source="registry_p2p"does not mean P2P served the read.metrics::registry_sourcelabels by whetheraccelerate_addressis non-empty, i.e. whether the request went through the facade. The facade's internal fallback to origin is still counted asregistry_p2p. So the metric cannot distinguish "served by a peer" from "proxied to the registry".handle_http_facade_result, alookup_descriptorreturningOk(Ok(None))falls through tofetch_origin_range_streamwith no log line, and a hit logs nothing either. Only failures log. So neither success nor the interesting failure mode is observable at any log level.Fix
The pre-redirect URL is available at the call site — it is the
urlargument tofetch_range_response. Pass it along on the accelerated request and let the facade key on it, while still fetching the fallback from the (presigned, unauthenticated) redirect target:registryfs_v2: sendx-agentenv-origin-blob-url: {url}on accelerated range requests.CanonicalBlobIdentityfrom that header when present, falling back to the request path.The header is never forwarded to the origin (
forwarded_headersonly passesAuthorizationandUser-Agent) and is never fetched, so it is a key hint only.Also worth doing, separately: log the hit path, and either relabel or add a metric that distinguishes peer-served bytes from facade-proxied bytes.
@yingdi-shan commented on GitHub (Aug 7, 2026):
Thanks for your contribution! Since P2P support is still experimental and hasn't been widely tested in production environments, your feedback is extremely valuable and will help us improve it significantly. We should probably also add a note to the documentation (or comments) to make users aware that the P2P feature is still experimental and its maturity is limited.
@dushulin commented on GitHub (Aug 7, 2026):
Fourth defect. With the first three fixed, most nodes served every read from a peer — but some cold creates still sent a burst of 57 range reads / 10 MB straight to the origin registry before P2P kicked in, and it was not reproducible on every node. Root cause: the accelerate address is only probed inside
create_image_file, butremote_runtimeis aOnceCellthat any caller can initialize first.What happens
enable_acceleration()is the only thing that probes the local P2P facade and flipsaccelerated:build_remote_runtime()reads the address that probe produces:and
current_accelerate_address()returns""whileacceleratedis still itsfalsedefault.Every
open_*_blob_with_sizepath goes throughremote_runtime().await?(get_or_try_init). So whichever caller touchesremote_runtimefirst wins theOnceCell— and if that caller is notcreate_image_file, the sharedRegistryFsV2is built with an empty accelerate address.create_image_fileprobes a moment later and patches the address onto the already-built registryfs, so all subsequent reads are accelerated. The opening burst is not.Measured
Cold create on one node,
overlaybd::image::image_service=debug+overlaybd::backend::registryfs_v2=debug:57 reads, 10,039,296 bytes, 31 distinct blobs, all at
offset=0— i.e. the layer index reads at the head of each blob, exactly the reads that decide how fast a cold create starts.Why it looked flaky
A node that already holds a local
.commitfor every layer resolves the whole image locally and never callsremote_runtime()at all, so it shows a perfectly cleannot-accelerated=0. Only nodes that actually have to leave the machine hit the window. That made this look like a per-node environment problem rather than an ordering bug.Fix
Probe in
build_remote_runtimerather than only increate_image_file.RegistryFsV2::from_global_configis constructed at exactly one site, so one line covers every caller:After the fix the same log shows the probe before the build:
Result with all four fixes
8 nodes,
remote-blocksblock cache cleared on every node, all pods restarted:source="registry_direct", zeroregistry range read not accelerated, zerofalling back to originp2p layer range served from peerline count matched theregistry_p2pmetric total (273/273, 357/357, 319/319, 263/263, 273/273; one node 321/327)closing database, 0socket closed, 0rebinding, 0 P2PERRORThe remaining 2 nodes issued no remote reads at all because they hold local commits for every layer — they were the seeders the other six pulled from. Re-tested as consumers (local commits parked, block cache and P2P store wiped) they also served everything from peers: 354/357 and 100%.
Note on method: wiping both seeders at once briefly leaves the swarm with no seeder for the image, which looks exactly like a P2P failure. Retire one seeder at a time.
Two diagnostics worth keeping regardless of this fix
Neither of these existed, and without them none of the four defects was visible:
debug!(%url, offset, count, "registry range read not accelerated")on the non-accelerated branch offetch_range_response— this is what made this defect visible at all, and it is the only way to attribute aregistry_directmetric to a concrete blob.debug!(accelerate_address, accelerated, runtime_initialized, ...)at both places that set the address — the pair that distinguishes an ordering bug from a dead facade.Fifth defect, found while chasing the last few origin reads on a cold node: a lookup timeout is stored as a negative cache entry, so one transient overshoot demotes that whole layer to the origin registry for the miss TTL.
lookup_descriptortreats all three non-hit outcomes identically and falls through to the cache insert:A timeout is "unknown", not "absent".
DEFAULT_LOOKUP_TIMEOUTis 300 ms andDEFAULT_DESCRIPTOR_MISS_CACHE_TTLis 5 s — and a cold create's entire read burst completes in well under 5 s, so a single 300 ms overshoot on the first lookup for a layer sends every remaining read of that layer to the origin, silently (the cached-miss path logs nothing).Measured
Cold node, no local commits, no block cache, empty P2P store, 7 healthy peers:
336 + 2 + 19 = 357. The 2 timeouts accounted for 19 reads, not 2: 2 initial plus 17 subsequent reads on those two now-negatively-cached layers. Both timeouts landed in the first 500 ms of the burst, when there is no scheduler hint yet and each lookup pays a fresh QUIC handshake against up to 7 peers — exactly when a 300 ms budget is most likely to be missed, and exactly when poisoning is most expensive.
A second run of the same node with a stable swarm served 100% from peers, 0 timeouts, 0 fallbacks — so this only bites the cold-start window, but the cold-start window is the whole point of P2P.
Fix
Don't cache the timeout branch:
Covered by a new test (
lookup_timeouts_are_not_cached_as_misses): a lookup slower thanlookup_timeoutmust leave the cache untouched, so the second read looks up again instead of going to the origin.Worth considering separately, not changed here:
Ok(Err(_))is arguably also not evidence of absence, and is currently cached bylookup_errors_are_cached_as_short_misseson purpose. Same argument as the timeout, different risk profile.