package api import ( "fmt" "net/http" "strconv" "time" ) // waitMaxHold / waitHeartbeat are effectively constants — vars only so tests can shrink them. var ( // waitMaxHold is how long the hub holds a wait before completing it with the (unchanged) // generation. Kept well under the ingress/NAT idle ceilings the transport spike measured // (SPIKE-immediate-sync-transport-2026-07-16.md): the nginx 60 s proxy_read_timeout is defeated // by the heartbeat below, and the operator NAT held an idle hold > 600 s. 240 s → a box // reconnects roughly every 4 min in steady state (negligible fleet load). waitMaxHold = 240 * time.Second // waitHeartbeat is the spacing of keepalive newline bytes written while holding. nginx's // proxy_read_timeout is measured BETWEEN successive reads from the upstream, so a byte every // 25 s keeps the default 60 s from ever firing — no ingress annotation / manifest change is // needed (§13 proves this live; the annotation is the documented fallback if it doesn't hold). // 25 s mirrors the WG PersistentKeepalive cadence and doubles as NAT keepalive. waitHeartbeat = 25 * time.Second ) // handleWait is the Direction-2 immediate-sync long-poll (option b of the transport spike). A box // holds this authenticated GET carrying its last-seen generation (?gen=N); the hub completes it the // instant any operator intent lands for the box's customer (intent.Hub.Bump from the web handlers), // or after waitMaxHold, whichever comes first. The response is CONTENTLESS — a single {"gen":N} // line preceded by heartbeat newlines. The box reacts by firing its ordinary out-of-cycle report; // the report ACK then delivers config/escrow/claim/floor through the UNCHANGED machinery. The hub // never connects inbound and never rides state on this channel (ground truth #1). func (h *Handler) handleWait(w http.ResponseWriter, r *http.Request) { customerID, isGlobal, ok := h.checkAuthCustomer(r) if !ok { http.Error(w, "Unauthorized", http.StatusUnauthorized) return } // Per-customer only: the key RESOLVES the customer and no customer_id parameter is accepted, // so customer A can never observe B's generation. A global operator key has no single customer // to wait on → 400 (the controller always holds a per-customer key). if isGlobal { http.Error(w, "wait is per-customer (present a customer key)", http.StatusBadRequest) return } if h.intentHub == nil { http.Error(w, "wait channel unavailable", http.StatusServiceUnavailable) return } var lastSeen uint64 if s := r.URL.Query().Get("gen"); s != "" { if v, err := strconv.ParseUint(s, 10, 64); err == nil { lastSeen = v } } // Lift THIS connection's write deadline past the hold. The global http.Server.WriteTimeout is // 60 s (main.go — deliberately untouched); http.NewResponseController overrides it per-connection // (Go 1.20+). Without this the held response is cut at 60 s regardless of the ingress. rc := http.NewResponseController(w) _ = rc.SetWriteDeadline(time.Now().Add(waitMaxHold + 30*time.Second)) w.Header().Set("Content-Type", "text/plain; charset=utf-8") w.Header().Set("X-Accel-Buffering", "no") // ask nginx not to buffer the streamed hold w.Header().Set("Cache-Control", "no-store") w.WriteHeader(http.StatusOK) if err := rc.Flush(); err != nil { return // client already gone } deadline := time.Now().Add(waitMaxHold) for { remaining := time.Until(deadline) if remaining <= 0 { // Hold elapsed with no change: complete with the current generation (Scenario B). h.writeWaitGen(w, rc, h.intentHub.Generation(customerID)) return } sub := remaining if sub > waitHeartbeat { sub = waitHeartbeat } gen := h.intentHub.Wait(r.Context(), customerID, lastSeen, sub) if gen != lastSeen { h.writeWaitGen(w, rc, gen) // intent moved → complete immediately return } // No change within this heartbeat window: emit a keepalive byte so the ingress read-timeout // never fires. A write/flush failure means the client is gone — return quietly (no log // noise, no goroutine leak; the intent.Wait above already deregistered). if _, err := w.Write([]byte("\n")); err != nil { return } if err := rc.Flush(); err != nil { return } } } // writeWaitGen emits the single contentless completion line and flushes. func (h *Handler) writeWaitGen(w http.ResponseWriter, rc *http.ResponseController, gen uint64) { fmt.Fprintf(w, "{\"gen\":%d}\n", gen) _ = rc.Flush() }