Files
Privatae e4c9b69b46
ci / personal-data (push) Successful in 5s
ci / test (push) Successful in 3m4s
ci / fuzz (push) Successful in 3m45s
bastion 0.53.2
2026-10-20 04:42:14 +00:00

75 lines
3.3 KiB
Go

package main
import (
"time"
"github.com/gorilla/websocket"
)
// The worker lanes (egress / mgmt / harness) are reverse-relay WebSockets the bastion
// opens TO the host and then PARKS on — blocked in ReadMessage until the host dispatches
// a job. The transport is the WireGuard tunnel through the host's pod-Hub. When that Hub's
// userspace netstack vanishes (a pod roll or Hub sidecar restart) the TCP session is
// severed with NO FIN/RST reaching us — so a parked ReadMessage blocks FOREVER and the
// lane stays dark until the process is restarted by hand. (The heartbeat and the
// per-request lanes self-heal because they dial per request; only these long-parked
// workers are affected.)
//
// armWSKeepalive makes that silently-dead tunnel observable. The bastion pings the host
// on an interval and holds a read deadline that every pong (or server-initiated ping)
// pushes forward. Lose the peer -> pongs stop -> the read deadline fires -> the parked
// ReadMessage returns an error -> the worker loop's reconnect kicks in. On a healthy lane
// the deadline never trips: the host's WebSocket layer answers our pings with pongs at the
// protocol level (below the app handler), so an idle-but-alive worker is refreshed every
// interval.
const (
wsPingInterval = 20 * time.Second
wsReadTimeout = 70 * time.Second // > 3 ping intervals: tolerate a couple of dropped pongs
wsWriteWait = 10 * time.Second
)
// armWSKeepalive installs the read deadline + pong/ping handlers and starts a background
// pinger for one worker connection. It MUST be called from the worker's own goroutine
// BEFORE that goroutine starts reading: SetReadDeadline / SetPongHandler / SetPingHandler
// are read-side state, and the returned stop() is the only other toucher of the ping
// goroutine. WriteControl (the ping) is safe to call concurrently with the worker's
// WriteMessage sends, per gorilla's concurrency contract. Call the returned stop() once
// the job finishes (normally) so the pinger exits; it is safe to call exactly once.
func armWSKeepalive(ws *websocket.Conn) (stop func()) {
return armWSKeepaliveEvery(ws, wsPingInterval, wsReadTimeout)
}
// armWSKeepaliveEvery is armWSKeepalive with injectable timings (tests use short ones).
func armWSKeepaliveEvery(ws *websocket.Conn, interval, timeout time.Duration) (stop func()) {
extend := func() { _ = ws.SetReadDeadline(time.Now().Add(timeout)) }
extend()
ws.SetPongHandler(func(string) error { extend(); return nil })
// A server-initiated ping is also proof of life: keep gorilla's default pong reply
// AND push the deadline (the host's server pings by default — belt and suspenders).
ws.SetPingHandler(func(appData string) error {
extend()
_ = ws.WriteControl(websocket.PongMessage, []byte(appData), time.Now().Add(wsWriteWait))
return nil
})
done := make(chan struct{})
go func() {
t := time.NewTicker(interval)
defer t.Stop()
for {
select {
case <-done:
return
case <-t.C:
if err := ws.WriteControl(websocket.PingMessage, nil, time.Now().Add(wsWriteWait)); err != nil {
// Write failed => the peer is gone and the buffer won't drain.
// Force-close so the parked reader unblocks immediately instead of
// waiting out the full read deadline.
_ = ws.Close()
return
}
}
}
}()
return func() { close(done) }
}