fix: fresh node's first query to a service succeeds on the first try - #474
Merged
Merged
Conversation
A fresh node's first `send-message list-agents --wait` failed most of the time on clean runners. The causes stack; this fixes the client half. daemon: - Reply window: a private node admits a SYN to port 1001/444 from a peer it dialed or sent a handshake to in the last 5 min, so a service's dial-back reply no longer needs a trust handshake first (was ~50 "SYN rejected: untrusted source" per failed first query). - Dials wait up to 10 s for the first-contact key exchange before spending SYN retries, queue one SYN instead of duplicates, and report "key exchange with peer did not complete" (wraps ErrDialTimeout). - A peer whose key arrived only over the relay is dialed via relay first (a stale registry endpoint no longer costs 1.75 s per dial). - Key request: an unanswered first-contact PILA is followed by a PILK, which every released daemon answers with a retransmitted PILA. Breaks the deadlock where the service's single reply was lost and it treats our same-key retransmits as keepalives. Also sent when a peer sends us encrypted frames we have no key for. - The key-exchange peer-trust check (event redaction only) uses local trust, not a synchronous registry CheckTrust on the read loop. - info reports "features" so pilotctl can adapt to the daemon. pilotctl send-message --wait: - no blocking auto-handshake when the daemon has the reply window; - one more dial on first contact while the path is converging; - the wait counts from the receiver's ack, ignores inbox files present before the send and takes the oldest new reply from the peer; - on first contact, re-sends the request once on a new stream if no reply by mid-window (--no-resend opts out; never for governed sends); - timeout and dial errors name the step that failed. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…iability # Conflicts: # pkg/daemon/ipc.go
| tracef("connectDriver") | ||
| defer d.Close() | ||
| // d may be replaced by a fresh connection when a dial is retried. | ||
| defer func() { d.Close() }() |
| // The SDK gave up waiting, the daemon may still be | ||
| // dialing: use a fresh connection so a late reply to | ||
| // the first dial cannot be taken for the second. | ||
| d.Close() |
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
TeoSlayer
enabled auto-merge (squash)
September 24, 2026 18:20
…iability # Conflicts: # cmd/pilotctl/main.go
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
On a clean machine, a fresh node's first
pilotctl --json send-message list-agents --data '/data {"search":"weather","limit":1}' --waitusually fails with{"code":"timeout","error":"no reply from \"0:0000.0002.BBE4\" within 30s"}. It typically succeeds on attempt 3, about 135 s in. This is not a v1.13.10 regression (v1.13.9 is worse), and under concurrent load (24 fresh nodes) almost every first attempt fails.Root cause (two investigations, reconciled)
Two independent traces ran on clean GitHub runners at debug level with pcap. They agree on the client-side mechanisms. They weight the server side differently, and the two accounts are complementary rather than conflicting.
Client side (both traces agree; fixed here):
SYN rejected: untrusted sourceper failed attempt, 244 in total in one trace. Trust only arrives through the handshake, and the relayed handshake is polled every 60 s, which matches "success at ~135 s".cannot connect) in 62 of 96 attempts.InboundDecryptStalerecovery never fires, becauseonKeyInstalledstampslastInboundDecryptjust before the check. The deadlock lasts until the peer's path watchdog drops its half, and our own PILAs keep postponing that. A failing unit test on main reproduced it.HandleAuthFramemade a synchronous registryCheckTrustfor every new peer's PILA only to decide whether an event may name the peer, which stalled every other inbound packet for that time.Server side (the traces differ in emphasis; needs the asks below): the second trace shows that list-agents' daemon is slow, independent of the directory app. It answers beacon punch commands after a median of 11.5 s (44% never), while pilot-mom takes 0.03 s and sibling personas on the same host take 0.11 s. It answered 6 of 36 port-7 echoes, against 35 of 36 for randomfox on the same host and version. No client change fixes that, but items 3-4 turn that slowness into a hard failure, and fixing them turns it back into latency.
Changes (web4 client + daemon)
Daemon
pkg/daemon/replywindow.go): the private-node SYN gate works like a stateful firewall. For 5 min after this node dials a peer, or sends it a trust handshake, a SYN from that peer to port 1001 or 444 is admitted. Only the contacted peer is admitted, only on those two ports, and the table is bounded (4096) and pruned by the idle sweep. Everything else still needs trust.DialKeyExchangeWait = 10s): while no session key exists, the dial keeps one queued SYN (no duplicates) and does not count retries. The queued SYN is flushed the moment the key arrives. If the key never arrives, the error iskey exchange with peer did not complete, which wrapsprotocol.ErrDialTimeout, so existingerrors.Ischecks still hold.relayProbeLoopstill tries to upgrade to direct.HandleUnauthFrame). The same PILK goes out when a peer sends us AEAD frames we have no key for. A PILK installs nothing on such a peer and costs 40 bytes per retransmit.SetPeerTrustFn(d.handshakeTrusts)uses local trust. A peer trusted only through the registry gets the redactedtunnel.establishedevent, which is the conservative side.inforeportsfeatures(reply_window,dial_awaits_key,key_request), so pilotctl can adapt to the daemon version it is talking to without comparing version strings.pilotctl
send-message --waitNo blocking auto-handshake when the daemon has
reply_window. With an older daemon the previous behaviour is kept. A private peer we don't trust is still refused up front.First contact is detected from the daemon's peer table (no encrypted session yet). On first contact, a dial that fails while the path is converging (timeout or key exchange) is retried once. If the SDK itself timed out, the retry uses a fresh driver connection.
--waitmatches the reply to the request:One re-send on first contact: if no reply has arrived by mid-window (at most 15 s), the request is sent once more on a new stream, and the window is extended so the second request gets at least half a window. The re-send is skipped with
--no-resend, for governed sends, and when the window is shorter than 6 s.Actionable errors: the timeout keeps its
no reply from "<addr>" within 30smessage, so scripts that match on it keep working, and adds ahintthat names the step that failed:JSON output also carries
first_contact,dial_attempts,resentandreply_after_ms.Deliberately not done
--wait/pilotctl inboxstill read$HOME/.pilot/inboxrather than honouringPILOT_HOME. Both use the same path, so they stay consistent with each other; a separate fix can change both together.Measurements (clean GitHub runners)
I ran an interleaved A/B on clean runners with the onboarding harness steps: official installer (stable v1.13.10), then
pilot-daemonandpilotctlswapped for one of two source builds,main(9adaede) or this branch. Each run didpilotctl daemon startand then the samesend-message list-agents ... --waitquery, up to 3 attempts. The matrix was ubuntu-latest, ubuntu-24.04-arm, macos-15 and macos-15-intel, × 3 reps × 2 variants, run as 2 rounds with max-parallel 4 so that each wave paired main and fix on the same OS (run 35990469128, attempts 1-2).cannot connect. The log shows 14no keyand 18gave up, which is the key-exchange deadlock signature.cannot connect/cannot resolveon all 3 attempts.no reply within 30son attempt 1, then OK on attempt 2.dial_attempts: 2, 30 s and 36 s end to end); every other query finished within 14 s. No re-send was needed in any job.Server-side asks (not in this PR)
0:0000.0002.BBE4).-endpoint), or run it-relay-only.real_addr.nstat -az UdpRcvbufErrors UdpInErrorsandss -uamp sport = :4615.key exchange rate-limited,cannot verify peer identity from registryand registry reconnect storms.pkg/daemon/keyexchange, a follow-up PR).ClearPendingRekeyin the!hadCryptobranch (handle.go:186-187).lastInboundDecrypt, whichonKeyInstalledstamps just before the check. This is daemon: path reset keeps the session so rekey desync can recover without a restart #465's stated follow-up.lookupPeerPubKey(d.reg().Lookuphas none today) and move it off the single read goroutine.pilot-agents/responder, live-service-agents list-agents).ReplyToto the request's MessageID. This PR makes the client ignore duplicates, but they still cost the service a dial-back each.isNATedis true for every0:address and stdin-modeHandshakeis a no-op, so the fallback never establishes trust.weatherhit, open-meteo-air-quality at0:0000.0000.4BBA, gets "node not found", as do open-meteo-flood, restcountries-all and dblp-publ-search. Prune them.-trust-auto-approve,ReportTrustandsendAcceptare fired as concurrent goroutines. SequenceReportTrustfirst, so the accept passes the client's registry trust check. Also raise or whitelist the default SYN limits (100/s global, 10/s per source) for directory agents;deploy-one.shpasses none.rapid_close,rate_limit_suspected=truein 20 of 21 CI job logs). Send an error frame instead of a silent close.maxPunchPerSecond = 10is fleet-wide (one punch per 100 ms for the whole network): make it per-target or raise it.relayDropped.Tests
GOWORK=off go test ./cmd/... ./pkg/daemon/... -count=1passes. New tests:pkg/daemon/zz_first_contact_test.go:ErrDialTimeout;pkg/daemon/keyexchange/zz_key_request_test.go: the key request rides on retransmits only, the due-mark sends it on the first try, no key request once a session is installed, and the peer answers a key request with a retransmitted PILA.cmd/pilotctl/zz_firstcontact_test.go: inbox snapshot and ordering, re-send once, window extension, resend-failure reporting, reply-connection counting, hints, dial-error classification, and the auto-handshake that no longer blocks (while kept for older daemons).TestCmdSendMessageJSONWaitSingleDocnow writes its reply after the send, since a pre-existing file is (correctly) no longer taken as the reply.🤖 Generated with Claude Code