Skip to content

Commit e80a68b

Browse files
author
Alex Godoroja
committed
feat: reply-on-connection — daemon --auto-answer + pilotctl --reply-on-conn (always-safe fallback)
1 parent 2488eb2 commit e80a68b

8 files changed

Lines changed: 603 additions & 11 deletions

File tree

CHANGELOG.md

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,42 @@ project uses [Semantic Versioning](https://semver.org/).
77
Detailed per-release notes are on the
88
[GitHub Releases page](https://github.com/TeoSlayer/pilotprotocol/releases).
99

10+
## [Unreleased]
11+
12+
### Added
13+
- **Reply-on-connection** — answers ride back on the connection the sender
14+
opened, eliminating the dial-back for opted-in requests. Two halves, each
15+
independently rollable and backwards-compatible:
16+
- **Daemon `--auto-answer <url>` (SPECIALISED — service/directory agents
17+
ONLY; regular nodes must not set it).** When set, a `TypeAutoAnswer` request
18+
is dispatched to `<url>` (the local responder's `/dispatch`) and the reply is
19+
written back on the same connection, then the connection closes after exactly
20+
one request + one reply. Plain requests are untouched and still use the inbox
21+
path, so enabling this never changes how the node serves ordinary senders.
22+
- **`pilotctl send-message --reply-on-conn`** (env `PILOT_REPLY_ON_CONN=1`) —
23+
sends the request as `TypeAutoAnswer` and reads the reply off the connection
24+
into `~/.pilot/inbox/` (same format as a dial-back reply), with no `--wait`
25+
and no dial-back. **Always safe — never worse than a plain send:** against an
26+
`--auto-answer` agent the reply comes on the connection; against any other
27+
agent it falls back to a normal dial-back (an updated agent saved it; an
28+
old/stock agent acks the frame as `UNKNOWN`, which triggers an automatic
29+
resend as plain `TEXT`). The on-connection benefit is gained only against
30+
`--auto-answer` agents.
31+
- The reply lands as an ordinary inbox message — `pilotctl inbox` reads it
32+
unchanged.
33+
34+
### Notes / limitations
35+
- Reply-on-connection helps senders that **run a client which reads the reply**
36+
(updated `pilotctl`/SDK). It does not change the ceiling for transient clients
37+
with no daemon/inbox.
38+
- The new loop is gated on the **request type**, so it is safe to enable
39+
`--auto-answer` on a live directory agent without disturbing current traffic.
40+
- **Sender-side fan-out is out of scope:** a single requester daemon opening
41+
*many* simultaneous dials (dozens) can see sender-side `dial` failures under
42+
NAT/relay pressure — a property of overloading one sender daemon, not of the
43+
receiver (which dropped zero replies in testing). Regular senders issue a few
44+
dials and are unaffected.
45+
1046
## [1.10.7] - 2026-06-06
1147

1248
### Fixed

cmd/daemon/main.go

Lines changed: 47 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,11 @@ import (
66
"context"
77
"flag"
88
"fmt"
9+
"io"
910
"log"
1011
"log/slog"
12+
"net/http"
13+
"net/url"
1114
"os"
1215
"os/signal"
1316
"strconv"
@@ -35,6 +38,34 @@ import (
3538

3639
var version = "dev"
3740

41+
// newAutoAnswerReplyHook builds the dataexchange ReplyHook for --auto-answer: it
42+
// dispatches each request to the responder's HTTP endpoint and returns the body
43+
// as a TEXT reply. The read is bounded at the data-exchange frame limit — the
44+
// same effective ceiling the normal inbox→dial-back path hits — so
45+
// reply-on-connection delivers byte-for-byte what a dial-back would, never
46+
// truncating a large reply. Returns ok=false (no reply written) on transport
47+
// error or an empty body, which lets the sender fall back to a dial-back.
48+
func newAutoAnswerReplyHook(endpoint string, hc *http.Client) func(reqType uint32, payload []byte) (uint32, []byte, bool) {
49+
return func(reqType uint32, payload []byte) (uint32, []byte, bool) {
50+
resp, err := hc.Get(endpoint + "?message=" + url.QueryEscape(string(payload)))
51+
if err != nil {
52+
return 0, nil, false
53+
}
54+
defer resp.Body.Close()
55+
// Only a 200 carries a real reply. 204 (dropped by loop-prevention) and
56+
// any error status (e.g. 400 "missing message") must NOT be delivered as
57+
// a reply — returning ok=false lets the sender fall back to a dial-back.
58+
if resp.StatusCode != http.StatusOK {
59+
return 0, nil, false
60+
}
61+
body, _ := io.ReadAll(io.LimitReader(resp.Body, dataexchange.MaxFrameSize))
62+
if len(body) == 0 {
63+
return 0, nil, false
64+
}
65+
return dataexchange.TypeText, body, true
66+
}
67+
}
68+
3869
func main() {
3970
configPath := flag.String("config", "", "path to config file (JSON)")
4071
registryDefault := "34.71.57.205:9000"
@@ -80,6 +111,7 @@ func main() {
80111
hostname := flag.String("hostname", "", "hostname for discovery (lowercase alphanumeric + hyphens, max 63 chars)")
81112
noEcho := flag.Bool("no-echo", false, "disable built-in echo service (port 7)")
82113
noDataExchange := flag.Bool("no-dataexchange", false, "disable built-in data exchange service (port 1001)")
114+
autoAnswer := flag.String("auto-answer", "", "SPECIALISED — directory/service agents ONLY (e.g. list-agents); regular nodes MUST leave this empty. Enables reply-on-connection: an opted-in request (TypeAutoAnswer, from `send-message --reply-on-conn`) is dispatched to this HTTP endpoint and the reply is written back on the SAME connection the sender opened, then the connection closes (exactly 1 request + 1 response — no loop). This lets a sender receive the answer in its inbox with no dial-back, even when it is unreachable. Plain requests are unaffected and still flow through the normal inbox path, so setting this never changes how this node serves ordinary senders. A regular node has no reason to hold connections open to generate replies and should never set this.")
83115
dataExchangeB64 := flag.Bool("dataexchange-b64", false, "include raw base64 payload (`data_b64`) alongside `data` in inbox messages — needed only for binary payloads (e.g. zlib-compressed envelopes)")
84116
noEventStream := flag.Bool("no-eventstream", false, "disable built-in event stream service (port 1002)")
85117
webhookURL := flag.String("webhook", "", "HTTP(S) endpoint for event notifications (empty = disabled)")
@@ -223,9 +255,21 @@ func main() {
223255
}
224256

225257
if !*noDataExchange {
226-
if err := rt.Register(dataexchange.NewService(dataexchange.ServiceConfig{
227-
IncludeBase64: *dataExchangeB64,
228-
})); err != nil {
258+
dxCfg := dataexchange.ServiceConfig{IncludeBase64: *dataExchangeB64}
259+
if *autoAnswer != "" {
260+
ru := *autoAnswer
261+
// Timeout chain (must be strictly increasing so no stage cuts off
262+
// the one beneath it): responder fetch (35s, the service call) <
263+
// this ReplyHook HTTP client (38s, the /dispatch round trip that
264+
// CONTAINS that fetch) < service autoAnswerWindow watchdog (40s) <=
265+
// sender on-connection read window (45s). At 38s this client gives
266+
// the responder's 35s fetch room to finish before the daemon gives up.
267+
hc := &http.Client{Timeout: 38 * time.Second}
268+
dxCfg.AutoAnswer = true
269+
dxCfg.ReplyHook = newAutoAnswerReplyHook(ru, hc)
270+
slog.Info("dataexchange auto-answer (reply-on-connection) enabled", "endpoint", ru)
271+
}
272+
if err := rt.Register(dataexchange.NewService(dxCfg)); err != nil {
229273
log.Fatalf("register dataexchange: %v", err)
230274
}
231275
}

cmd/daemon/zz_autoanswer_test.go

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+
package main
4+
5+
import (
6+
"net/http"
7+
"net/http/httptest"
8+
"strings"
9+
"testing"
10+
"time"
11+
12+
"github.com/pilot-protocol/dataexchange"
13+
)
14+
15+
// TestAutoAnswerReplyHook_LargeBodyNotTruncated proves the reply-on-connection
16+
// read is NOT capped at the old 1 MiB limit: a body well over 1 MiB is returned
17+
// in full, so a large directory reply is delivered byte-for-byte like a
18+
// dial-back would.
19+
func TestAutoAnswerReplyHook_LargeBodyNotTruncated(t *testing.T) {
20+
const size = 3 << 20 // 3 MiB, well past the retired 1 MiB cap
21+
big := strings.Repeat("x", size)
22+
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
23+
if r.URL.Query().Get("message") == "" {
24+
t.Error("reply hook did not forward the message query param")
25+
}
26+
_, _ = w.Write([]byte(big))
27+
}))
28+
defer srv.Close()
29+
30+
hook := newAutoAnswerReplyHook(srv.URL, &http.Client{Timeout: 10 * time.Second})
31+
rt, body, ok := hook(dataexchange.TypeAutoAnswer, []byte("/data"))
32+
if !ok {
33+
t.Fatal("hook returned ok=false for a valid large body")
34+
}
35+
if rt != dataexchange.TypeText {
36+
t.Errorf("reply type = %d, want TypeText", rt)
37+
}
38+
if len(body) != size {
39+
t.Fatalf("reply truncated: got %d bytes, want %d", len(body), size)
40+
}
41+
}
42+
43+
// TestAutoAnswerReplyHook_EmptyAndError: an empty body or a transport error
44+
// yields ok=false (no reply on the connection → the sender falls back).
45+
func TestAutoAnswerReplyHook_EmptyAndError(t *testing.T) {
46+
// Empty body (e.g. /dispatch 204 for a dropped message).
47+
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
48+
w.WriteHeader(http.StatusNoContent)
49+
}))
50+
hook := newAutoAnswerReplyHook(srv.URL, &http.Client{Timeout: 5 * time.Second})
51+
if _, _, ok := hook(dataexchange.TypeAutoAnswer, []byte("prose")); ok {
52+
t.Error("empty body should yield ok=false")
53+
}
54+
srv.Close()
55+
56+
// Transport error (server gone).
57+
if _, _, ok := hook(dataexchange.TypeAutoAnswer, []byte("/data")); ok {
58+
t.Error("transport error should yield ok=false")
59+
}
60+
61+
// Non-200 with a non-empty body (e.g. 400 "missing message") must NOT be
62+
// delivered as a reply.
63+
errSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
64+
http.Error(w, "missing message", http.StatusBadRequest)
65+
}))
66+
defer errSrv.Close()
67+
h2 := newAutoAnswerReplyHook(errSrv.URL, &http.Client{Timeout: 5 * time.Second})
68+
if _, body, ok := h2(dataexchange.TypeAutoAnswer, []byte("/data")); ok {
69+
t.Errorf("a 400 response must yield ok=false, got body=%q", body)
70+
}
71+
}

cmd/pilotctl/main.go

Lines changed: 159 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3649,6 +3649,39 @@ func cmdSendMessage(args []string) {
36493649
}
36503650
msgType := flagString(flags, "type", "text")
36513651

3652+
// --reply-on-conn opts this request into reply-on-connection: send it as a
3653+
// TypeAutoAnswer frame and read the reply the receiver writes back on the
3654+
// SAME connection (into the inbox), instead of relying on a dial-back.
3655+
//
3656+
// ALWAYS SAFE — never worse than a plain send. Against an --auto-answer agent
3657+
// the reply rides back on this connection (so it works even when this sender
3658+
// is NAT'd / has no public port / is transient). Against ANY other agent it
3659+
// transparently falls back to a normal dial-back: an updated agent saved the
3660+
// request, and an old/stock agent (which acks the frame as UNKNOWN) triggers
3661+
// an automatic resend as plain TEXT — either way the reply is dial-backed
3662+
// exactly as for a normal send. Current senders that don't set it are
3663+
// untouched. Env: PILOT_REPLY_ON_CONN=1.
3664+
replyOnConn := flagBool(flags, "reply-on-conn") || os.Getenv("PILOT_REPLY_ON_CONN") != ""
3665+
if replyOnConn {
3666+
// reply-on-connection carries the request as a text query, so true binary
3667+
// would be lossily url-escaped — reject it rather than diverge silently
3668+
// from a plain --type binary send.
3669+
if msgType == "binary" {
3670+
fatalCode("invalid_argument", "--reply-on-conn does not support --type binary")
3671+
}
3672+
// --trace sends a TypeTrace frame for timing and is incompatible with
3673+
// reply-on-connection; trace wins, so disable reply-on-conn handling to
3674+
// avoid a spurious fallback resend (double inbox entry).
3675+
if traceTime {
3676+
replyOnConn = false
3677+
}
3678+
// An --auto-answer receiver closes after one request+reply, so a reused
3679+
// connection can't carry a second send. Dial fresh per send instead.
3680+
if reuseConn {
3681+
reuseConn = false
3682+
}
3683+
}
3684+
36523685
// Auto-handshake to peers in the embedded trusted-agents list.
36533686
// Best-effort: warns on stderr and continues if handshake fails.
36543687
maybeAutoHandshake(d, target, flagBool(flags, "no-auto-handshake"))
@@ -3687,12 +3720,16 @@ func cmdSendMessage(args []string) {
36873720
sentAtNs, sendErr = cl.SendTrace(innerType, []byte(data))
36883721
} else {
36893722
sendStart := time.Now()
3690-
switch msgType {
3691-
case "text":
3723+
switch {
3724+
case replyOnConn:
3725+
// Opt in to reply-on-connection (TypeAutoAnswer); the reply is
3726+
// read off this connection below and saved to the inbox.
3727+
sendErr = cl.SendAutoAnswer(data)
3728+
case msgType == "text":
36923729
sendErr = cl.SendText(data)
3693-
case "json":
3730+
case msgType == "json":
36943731
sendErr = cl.SendJSON([]byte(data))
3695-
case "binary":
3732+
case msgType == "binary":
36963733
sendErr = cl.SendBinary([]byte(data))
36973734
}
36983735
sentAtNs = sendStart.UnixNano()
@@ -3713,7 +3750,62 @@ func cmdSendMessage(args []string) {
37133750
"reused": reused,
37143751
}
37153752
if ack != nil {
3716-
r["ack"] = string(ack.Payload)
3753+
ackStr := string(ack.Payload)
3754+
r["ack"] = ackStr
3755+
// reply-on-connection delivery + GUARANTEED fallback. With
3756+
// --reply-on-conn we sent a TypeAutoAnswer request; decide whether a
3757+
// reply is (or will be) delivered, and if not, fall back so the flag
3758+
// is ALWAYS at least as good as a plain send:
3759+
// * ACK+REPLY → an --auto-answer agent's reply follows on
3760+
// THIS connection; read it into the inbox.
3761+
// * ack names the AUTOANSWER type → an updated, non-auto-answering
3762+
// agent saved the request; it will dial back.
3763+
// * anything else (an OLD/stock daemon acks UNKNOWN and does NOT
3764+
// save it; or the on-connection read failed)
3765+
// → resend as plain TEXT so ANY daemon saves
3766+
// it and the responder dial-backs the reply.
3767+
if replyOnConn {
3768+
delivered := false
3769+
switch replyOnConnOutcome(ackStr) {
3770+
case replyOnConnRead:
3771+
// Read window is tied to the receiver's autoAnswerWindow (40s)
3772+
// plus margin — NOT to --wait. --wait bounds the later inbox
3773+
// poll; capping the on-connection read by a small --wait would
3774+
// abandon a reply the receiver has already committed to send
3775+
// (it skipped the inbox) and force a needless resend.
3776+
_ = cl.SetReadDeadline(time.Now().Add(45 * time.Second))
3777+
reply, rerr := cl.Recv()
3778+
_ = cl.SetReadDeadline(time.Time{})
3779+
if rerr == nil && reply != nil && len(reply.Payload) > 0 {
3780+
if p, serr := saveReplyToInbox(target.String(), reply.Type, reply.Payload); serr == nil {
3781+
r["reply_bytes"] = len(reply.Payload)
3782+
r["reply_inbox"] = p
3783+
delivered = true
3784+
} else {
3785+
r["reply_error"] = serr.Error()
3786+
}
3787+
} else if rerr != nil {
3788+
r["reply_error"] = rerr.Error()
3789+
}
3790+
case replyOnConnDelivered:
3791+
// Understood by an updated daemon and saved for dial-back.
3792+
delivered = true
3793+
}
3794+
if !delivered {
3795+
// Old/stock receiver (or a lost on-connection reply): resend
3796+
// as a plain TEXT message — exactly the pre-feature path — so
3797+
// the receiver saves it and the reply is dial-backed. This is
3798+
// what makes --reply-on-conn never worse than a plain send.
3799+
if rc, derr := dataexchange.Dial(d, target); derr == nil {
3800+
_ = rc.SendText(data)
3801+
_, _ = rc.Recv() // consume ack
3802+
_ = rc.Close()
3803+
r["fallback"] = "dialback"
3804+
} else {
3805+
r["fallback_error"] = derr.Error()
3806+
}
3807+
}
3808+
}
37173809
}
37183810
if traceTime {
37193811
r["total_ms"] = float64(time.Duration(ackRecvAtNs-sentAtNs).Microseconds()) / 1000.0
@@ -5273,9 +5365,70 @@ func cmdReceived(args []string) {
52735365
fmt.Printf("\ntotal: %d\n", len(files))
52745366
}
52755367

5368+
// reply-on-connection sender outcomes, decided from the receiver's ack.
5369+
const (
5370+
replyOnConnRead = "read" // --auto-answer agent: read the reply on this connection
5371+
replyOnConnDelivered = "delivered" // updated agent understood + saved it: a dial-back is coming
5372+
replyOnConnResend = "resend" // old/stock agent did not understand it: resend as plain TEXT
5373+
)
5374+
5375+
// replyOnConnOutcome decides what a --reply-on-conn sender must do, from the
5376+
// receiver's ack payload. This is the core of the "always safe" guarantee: only
5377+
// an "ACK+REPLY" ack carries a reply on the connection; only a daemon that knows
5378+
// the AUTOANSWER type echoes it in the ack (so it understood and saved the
5379+
// request for dial-back); everything else is an old/stock daemon that acked the
5380+
// frame as UNKNOWN without saving it, so the sender must resend as plain TEXT.
5381+
func replyOnConnOutcome(ackPayload string) string {
5382+
switch {
5383+
case strings.HasPrefix(ackPayload, "ACK+REPLY"):
5384+
return replyOnConnRead
5385+
case strings.Contains(ackPayload, dataexchange.TypeName(dataexchange.TypeAutoAnswer)):
5386+
return replyOnConnDelivered
5387+
default:
5388+
return replyOnConnResend
5389+
}
5390+
}
5391+
5392+
// saveReplyToInbox writes a reply-on-connection frame to ~/.pilot/inbox/ in the
5393+
// exact shape the daemon's dataexchange service uses, so it is indistinguishable
5394+
// from a dial-back reply to `pilotctl inbox` and any inbox tooling. Returns the
5395+
// written path. This is how a reply-aware sender lands the answer in its own
5396+
// inbox without ever being dialed back.
5397+
func saveReplyToInbox(from string, frameType uint32, payload []byte) (string, error) {
5398+
home, err := os.UserHomeDir()
5399+
if err != nil {
5400+
return "", err
5401+
}
5402+
dir := filepath.Join(home, ".pilot", "inbox")
5403+
if err := os.MkdirAll(dir, 0700); err != nil {
5404+
return "", err
5405+
}
5406+
ts := time.Now()
5407+
msg := map[string]interface{}{
5408+
"type": dataexchange.TypeName(frameType),
5409+
"from": from,
5410+
"data": string(payload),
5411+
"bytes": len(payload),
5412+
"received_at": ts.Format(time.RFC3339Nano),
5413+
}
5414+
data, err := json.Marshal(msg)
5415+
if err != nil {
5416+
return "", err
5417+
}
5418+
// Nanosecond stamp + pid keep the filename unique without the daemon's
5419+
// shared atomic sequence counter.
5420+
filename := fmt.Sprintf("%s-%s-%06d.json", dataexchange.TypeName(frameType),
5421+
ts.Format("20060102-150405.000000000"), os.Getpid()%1000000)
5422+
destPath := filepath.Join(dir, filename)
5423+
if err := os.WriteFile(destPath, data, 0600); err != nil {
5424+
return "", err
5425+
}
5426+
return destPath, nil
5427+
}
5428+
52765429
// cmdInbox lists or clears messages received via data exchange (port 1001).
52775430
// waitForInboxReply polls ~/.pilot/inbox/ until a JSON file arrives that is
5278-
// newer than cutoff and (if agentHint is non-empty) has a matching "agent"
5431+
// newer than cutoff and (if agentHint is non-empty) has a matching "from"
52795432
// field. Returns the parsed message or an error on timeout.
52805433
func waitForInboxReply(agentHint string, cutoff time.Time, timeout time.Duration) (map[string]interface{}, error) {
52815434
home, err := os.UserHomeDir()

0 commit comments

Comments
 (0)