Files
felhom.eu/hub/internal/api/wait.go
T
admin 60244727ad feat(hub): Direction-2 immediate-sync wait channel (v0.58.0)
GET /api/v1/wait long-poll: the box holds an authed hanging GET; the hub
completes it the instant any operator intent bumps that customer's in-memory
generation, then the box fires its ordinary report and the ACK delivers
everything through the unchanged machinery. 240s hold with a 25s heartbeat
newline defeats the nginx 60s proxy_read_timeout with no ingress annotation;
WriteTimeout lifted per-connection via ResponseController.

- internal/intent: per-customer generation counter + waiter registry
  (Bump/Wait/Close), coalescing to latest, race-closer, in-memory by design.
  Red-proofs: counter-vs-queue + race-closer (run-fail-reverted).
- api/wait.go: the endpoint (per-customer only; global key 400; A cannot see B).
- web bumps after every intent write (fire-after-commit): config CRUD, claim
  resend, offsite re-issue/freeze, password regen, block/unblock, floors
  (global bumps all config-managed), controller log-tail + log-bundle.
- main.go: one intent hub shared by web+api; Close() before server.Shutdown.

Pairs with controller v0.140.0 (the long-poll client). Grounding:
documentation/audits/SPIKE-immediate-sync-transport-2026-07-16.md.
2026-07-16 20:44:22 +02:00

106 lines
4.4 KiB
Go

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()
}