carlos / platform Public

edge: fleet channels ping at 5s/5s — reap bound is the failover window (D7)

A fleet route's failover is "requests fall back to a surviving box once
the dead channel is reaped", so pingLoop's reap bound IS the customer-
visible outage window. Dynamic (registry-backed, "<fleet>/<label>")
channels now ping every 5s with a 5s deadline — a <=10s window instead of
the static channels' ~25s. Static platform channels keep 15s/10s: their
routes have no fallback box, so a tighter ping buys no availability and
only adds churn risk.

ServeHTTP already learns which credential source admitted a conn; that
fact is now carried onto tunnelConn.dynamic and into pingLoop, which
selects its cadence through pingCadence. The delete-if-still-live guard
and re-register semantics are untouched.

Tests pin the SELECTION, not wall-clock ping timing: pingCadence both
ways (with the static constants spelled out), and a real two-channel
handshake proving a dynamic-auth'd conn is marked dynamic while a static
one is not.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell a16872623d4eef617a9817937b94ddf3a1caa7ee parent 03ff001
3 files changed, +111 −15
  • internal/edge/fleet_e2e_test.go +11 −10
  • internal/edge/tunnel.go +37 −5
  • internal/edge/tunnel_test.go +63 −0
diff --git a/internal/edge/fleet_e2e_test.go b/internal/edge/fleet_e2e_test.go
index b48fcaaec..94a04565f 100644
--- a/internal/edge/fleet_e2e_test.go
+++ b/internal/edge/fleet_e2e_test.go
@@ -415,14 +415,15 @@ func TestTwoBoxFleetEndToEnd(t *testing.T) {
// T4 CARRY (observation, not a gate): the edge does not learn a channel
// died from the channel dying. Nothing unregisters a dead conn except
- // pingLoop — tunnelPingEvery (15s) + tunnelPingTimeout (10s), so up to
- // ~25s of black hole in which the hub still reports the box connected and
- // rule 1 keeps dispatching to a corpse instead of falling over to the
- // healthy survivor. That window IS the venue-Wi-Fi failure mode: an app
- // stays down for ~25s after its box drops off the network, even though
- // another box could have served it immediately. Observed here without
- // paying the 25s: the assertions below are that the channel is STILL
- // registered and the request does NOT reach box-a.
+ // pingLoop — for a fleet channel that is fleetPingEvery (5s) +
+ // fleetPingTimeout (5s), so up to ~10s of black hole (spec D7 narrowed it
+ // from the static channels' ~25s) in which the hub still reports the box
+ // connected and rule 1 keeps dispatching to a corpse instead of falling
+ // over to the healthy survivor. That window IS the venue-Wi-Fi failure
+ // mode: an app stays down for up to ~10s after its box drops off the
+ // network, even though another box could have served it immediately.
+ // Observed here without paying the 10s: the assertions below are that the
+ // channel is STILL registered and the request does NOT reach box-a.
if got := hub.State(e2eFleet + "/box-b"); got != "connected" {
t.Fatalf("(b) hub state for the dead box = %q, want %q — if the hub now reaps eagerly, the black-hole note above is stale and this test should be updated to assert the better behavior", got, "connected")
}
@@ -431,7 +432,7 @@ func TestTwoBoxFleetEndToEnd(t *testing.T) {
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
if resp.StatusCode == http.StatusOK && string(body) == "box-a" {
- t.Fatal("(b) the request fell over to box-a before the dead channel was reaped — good news, but it means the ~25s black-hole window this test documents is gone and the comment above needs rewriting")
+ t.Fatal("(b) the request fell over to box-a before the dead channel was reaped — good news, but it means the ~10s black-hole window this test documents is gone and the comment above needs rewriting")
}
t.Logf("(b) black-hole window: dead-but-registered channel answered %d %q (no fallover)", resp.StatusCode, string(body))
} else {
@@ -440,7 +441,7 @@ func TestTwoBoxFleetEndToEnd(t *testing.T) {
// pingLoop's reap, on demand — CloseChannel is exactly what it calls, so
// the rest of the sequence runs at the speed the fleet would see once the
- // ping has fired, not 25s later.
+ // ping has fired, not 10s later.
hub.CloseChannel(e2eFleet + "/box-b")
waitChannel(t, hub, e2eFleet+"/box-b", "", time.Second)
diff --git a/internal/edge/tunnel.go b/internal/edge/tunnel.go
index fc9eda944..d40999a60 100644
--- a/internal/edge/tunnel.go
+++ b/internal/edge/tunnel.go
@@ -48,11 +48,34 @@ const (
// notices its conn die and re-dials (seconds — the bound Task 10 asserts).
tunnelPingEvery = 15 * time.Second
tunnelPingTimeout = 10 * time.Second
+ // Fleet (dynamic) channels ping tighter (fleets-console spec D7): a
+ // customer fleet's failover is "requests fall back to a surviving box once
+ // the dead channel is reaped", so the reap bound IS the user-visible
+ // outage window — ≤10s (one 5s sleep + one 5s deadline) instead of the
+ // static channels' ~25s. Static platform channels (tito hq) keep the wider
+ // cadence: their routes have no fallback box, so a tighter ping only adds
+ // churn risk for no availability gain.
+ fleetPingEvery = 5 * time.Second
+ fleetPingTimeout = 5 * time.Second
)
+// pingCadence picks a channel's liveness cadence from which credential source
+// admitted it. Split out from pingLoop so the choice is testable without
+// waiting on wall-clock pings.
+func pingCadence(dynamic bool) (every, timeout time.Duration) {
+ if dynamic {
+ return fleetPingEvery, fleetPingTimeout
+ }
+ return tunnelPingEvery, tunnelPingTimeout
+}
+
type tunnelConn struct {
cc *http2.ClientConn
raw net.Conn
+ // dynamic records that this conn authenticated against the registry-backed
+ // (fleet) credential source rather than the static env map — the one fact
+ // ServeHTTP knows and pingLoop needs for pingCadence.
+ dynamic bool
}
type TunnelHub struct {
@@ -126,6 +149,10 @@ func (h *TunnelHub) ServeHTTP(w http.ResponseWriter, r *http.Request) {
// there is exactly one 401 below regardless of which source (or neither)
// missed; a caller cannot distinguish "wrong static token" from "unknown
// dynamic channel" from "wrong dynamic token" by response shape.
+ // viaDynamic is that branch's other output: which source admitted this
+ // conn decides its ping cadence (pingCadence), and this is the only place
+ // that fact is known.
+ viaDynamic := false
if !authOK && name != "" && hasBearer {
h.mu.Lock()
wantHash, dynOK := h.dynamicAuth[name]
@@ -134,6 +161,7 @@ func (h *TunnelHub) ServeHTTP(w http.ResponseWriter, r *http.Request) {
sum := sha256.Sum256([]byte(token))
gotHash := hex.EncodeToString(sum[:])
authOK = subtle.ConstantTimeCompare([]byte(gotHash), []byte(wantHash)) == 1
+ viaDynamic = authOK
}
}
if !authOK {
@@ -189,19 +217,23 @@ func (h *TunnelHub) ServeHTTP(w http.ResponseWriter, r *http.Request) {
if old := h.conns[name]; old != nil {
old.raw.Close() // a re-register replaces the previous channel
}
- h.conns[name] = &tunnelConn{cc: cc, raw: conn}
+ h.conns[name] = &tunnelConn{cc: cc, raw: conn, dynamic: viaDynamic}
h.mu.Unlock()
log.Printf("edge: tunnel %s: connected from %s", name, conn.RemoteAddr())
- go h.pingLoop(name, cc, conn)
+ go h.pingLoop(name, cc, conn, viaDynamic)
}
// pingLoop is the edge-side liveness check: a dead ping closes the raw conn
// and unregisters the channel, so requests fail fast to 502 instead of
// hanging on a black-holed TCP session (the venue-Wi-Fi failure mode).
-func (h *TunnelHub) pingLoop(name string, cc *http2.ClientConn, raw net.Conn) {
+// dynamic carries the credential source through from ServeHTTP: fleet
+// channels reap on a tighter bound because that bound is their failover
+// window (see pingCadence).
+func (h *TunnelHub) pingLoop(name string, cc *http2.ClientConn, raw net.Conn, dynamic bool) {
+ every, timeout := pingCadence(dynamic)
for {
- time.Sleep(tunnelPingEvery)
- ctx, cancel := context.WithTimeout(context.Background(), tunnelPingTimeout)
+ time.Sleep(every)
+ ctx, cancel := context.WithTimeout(context.Background(), timeout)
err := cc.Ping(ctx)
cancel()
if err != nil {
diff --git a/internal/edge/tunnel_test.go b/internal/edge/tunnel_test.go
index 4c48acfba..3f7b278c7 100644
--- a/internal/edge/tunnel_test.go
+++ b/internal/edge/tunnel_test.go
@@ -11,6 +11,7 @@ import (
"os"
"strings"
"testing"
+ "time"
"github.com/carlosframework/platform/internal/registry"
)
@@ -637,3 +638,65 @@ func TestTunnelRouteIsNeverActivatorManaged(t *testing.T) {
t.Fatalf("activator woke a local process for a tunnel-served host (%d call(s))", act.served)
}
}
+
+// TestPingCadence pins the cadence SELECTION rather than wall-clock ping
+// timing: a fleet (dynamic) channel's reap bound is the customer-visible
+// failover window, so it pings tighter, while the static platform channels
+// keep the wider cadence they have always had. Asserting the selection is
+// what lets this be a millisecond test instead of a 25-second one.
+func TestPingCadence(t *testing.T) {
+ if e, to := pingCadence(false); e != tunnelPingEvery || to != tunnelPingTimeout {
+ t.Fatalf("static cadence changed: got %s/%s, want %s/%s", e, to, tunnelPingEvery, tunnelPingTimeout)
+ }
+ // The static numbers themselves, spelled out: a future edit that widened
+ // or narrowed them would otherwise pass the comparison above unnoticed.
+ if tunnelPingEvery != 15*time.Second || tunnelPingTimeout != 10*time.Second {
+ t.Fatalf("static cadence constants moved: %s/%s, want 15s/10s", tunnelPingEvery, tunnelPingTimeout)
+ }
+ if e, to := pingCadence(true); e != fleetPingEvery || to != fleetPingTimeout {
+ t.Fatalf("fleet cadence wrong: got %s/%s, want %s/%s", e, to, fleetPingEvery, fleetPingTimeout)
+ }
+ if fleetPingEvery != 5*time.Second || fleetPingTimeout != 5*time.Second {
+ t.Fatalf("fleet cadence constants moved: %s/%s, want 5s/5s", fleetPingEvery, fleetPingTimeout)
+ }
+}
+
+// TestDynamicChannelMarkedDynamic is the other half of the cadence: knowing
+// WHICH cadence to use means the conn has to remember which credential source
+// admitted it, and only a real handshake (httptest.NewRecorder cannot hijack)
+// ever reaches the point where a conn is stored. Both kinds register against
+// ONE hub, so this also re-proves the two credential sources coexist.
+func TestDynamicChannelMarkedDynamic(t *testing.T) {
+ hub := NewTunnelHub("unused.test", map[string]string{"hq": "sekrit"})
+ hub.SetDynamicAuth(map[string]string{"pi/box-a": sha256Hex("tok")})
+ // The hub answers registration directly here: no route, no edge, no
+ // reserved-hostname routing — this test is about what ServeHTTP stores.
+ srv := httptest.NewServer(hub)
+ defer srv.Close()
+
+ ok := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})
+ stopDyn := startFakeRemoteHost(t, srv.URL, "pi/box-a", "tok", ok)
+ defer stopDyn()
+ stopStatic := startFakeRemoteHost(t, srv.URL, "hq", "sekrit", ok)
+ defer stopStatic()
+ waitForTunnelState(t, hub, "pi/box-a", "connected", e2eReconnectBound)
+ waitForTunnelState(t, hub, "hq", "connected", e2eReconnectBound)
+
+ if !channelIsDynamic(t, hub, "pi/box-a") {
+ t.Fatal("a channel admitted by the dynamic (fleet) credential source was not marked dynamic — it would ping at the static cadence and its fleet's failover window would be ~25s")
+ }
+ if channelIsDynamic(t, hub, "hq") {
+ t.Fatal("a static env channel was marked dynamic — platform channels must keep the wider cadence")
+ }
+}
+
+func channelIsDynamic(t *testing.T, hub *TunnelHub, name string) bool {
+ t.Helper()
+ hub.mu.Lock()
+ defer hub.mu.Unlock()
+ c := hub.conns[name]
+ if c == nil {
+ t.Fatalf("channel %q is not registered", name)
+ }
+ return c.dynamic
+}