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>
3 files changed,
+111
−15
internal/edge/fleet_e2e_test.go+11 −10internal/edge/tunnel.go+37 −5internal/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 |
| +} |