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.
This commit is contained in:
2026-07-16 20:44:22 +02:00
parent 10e07f5747
commit 60244727ad
11 changed files with 790 additions and 0 deletions
+143
View File
@@ -0,0 +1,143 @@
// Package intent is the hub's in-memory operator-intent notifier — the server side of the
// Direction-2 immediate-sync arc (SPIKE-immediate-sync-transport-2026-07-16.md, option b).
//
// Every operator action that changes a customer's desired state (config save/delete, claim
// resend, offsite re-issue/freeze, floor change, block/unblock, log-pull request) Bumps that
// customer's generation. A box holds a hanging GET /api/v1/wait against the hub carrying its
// last-seen generation; Wait returns the instant the generation moves (or on timeout/shutdown).
// The box then fires its ordinary out-of-cycle report — the ACK delivers everything through the
// UNCHANGED report machinery. The wait signal is CONTENTLESS: it carries a generation number
// and nothing else (no config, no escrow, no claim).
//
// The counter is IN-MEMORY BY DESIGN — a restart resets all generations to zero, which the box
// treats as "a different generation" (it compares with != , not >), firing exactly one harmless
// full-state report before settling. No persistence, no store schema (Part 2.1 / §5).
//
// Semantics mirror the wgsync reconciler / controller report.Trigger: a per-customer counter,
// coalescing (N bumps during one hold → one completion carrying the LATEST generation), never a
// per-bump queue. Every Wait exit path deregisters its channel, so there is no waiter leak.
package intent
import (
"context"
"sync"
"time"
)
// Hub is the in-memory per-customer generation counter + waiter registry. The zero value is not
// usable; construct with New. All methods are safe for concurrent use.
type Hub struct {
mu sync.Mutex
gen map[string]uint64 // customerID -> current generation (0 = never bumped)
waiters map[string][]chan struct{} // customerID -> registered wake channels (buffered, cap 1)
closed chan struct{} // closed by Close() to wake every waiter
isClosed bool
}
// New builds an empty intent hub.
func New() *Hub {
return &Hub{
gen: make(map[string]uint64),
waiters: make(map[string][]chan struct{}),
closed: make(chan struct{}),
}
}
// Bump advances the customer's generation and wakes every currently-registered waiter for that
// customer, then clears them (each waiter re-reads the LATEST generation under the lock on wake,
// so a burst of bumps collapses into one completion carrying the final value — a counter, not a
// queue). Safe from any goroutine; never blocks. An empty customerID is a no-op, as is any bump
// after Close.
func (h *Hub) Bump(customerID string) {
if customerID == "" {
return
}
h.mu.Lock()
defer h.mu.Unlock()
if h.isClosed {
return
}
h.gen[customerID]++
for _, ch := range h.waiters[customerID] {
// Non-blocking send into a cap-1 channel: the waiter reads the latest gen under the lock,
// so a single wake is all it needs; a second bump before the waiter runs is harmless.
select {
case ch <- struct{}{}:
default:
}
}
delete(h.waiters, customerID)
}
// Wait blocks until the customer's generation differs from lastSeen, ctx is cancelled, maxHold
// elapses, or Close is called — whichever first — and returns the customer's CURRENT generation
// to hand back to the caller. If the generation already differs from lastSeen at entry (a bump
// landed before this wait registered), it returns immediately: the race gap between a bump and a
// (re)connect is closed by this pre-register check, so a bump is never lost.
func (h *Hub) Wait(ctx context.Context, customerID string, lastSeen uint64, maxHold time.Duration) uint64 {
h.mu.Lock()
if h.isClosed {
cur := h.gen[customerID]
h.mu.Unlock()
return cur
}
if cur := h.gen[customerID]; cur != lastSeen {
h.mu.Unlock()
return cur // race-closer: intent already moved; no need to hold
}
ch := make(chan struct{}, 1)
h.waiters[customerID] = append(h.waiters[customerID], ch)
h.mu.Unlock()
timer := time.NewTimer(maxHold)
defer timer.Stop()
select {
case <-ch:
case <-ctx.Done():
case <-timer.C:
case <-h.closed:
}
return h.finish(customerID, ch)
}
// finish deregisters ch (if a Bump/Close hasn't already cleared it) and returns the customer's
// current — i.e. latest — generation. Called on every Wait exit path, so waiters never leak.
func (h *Hub) finish(customerID string, ch chan struct{}) uint64 {
h.mu.Lock()
defer h.mu.Unlock()
ws := h.waiters[customerID]
for i, c := range ws {
if c == ch {
h.waiters[customerID] = append(ws[:i], ws[i+1:]...)
break
}
}
if len(h.waiters[customerID]) == 0 {
delete(h.waiters, customerID)
}
return h.gen[customerID]
}
// Close wakes every registered waiter (each returns its customer's current generation) and makes
// all subsequent Wait calls return immediately. Idempotent. Wired into the hub's graceful
// shutdown BEFORE server.Shutdown so held waits complete instantly instead of eating the grace
// window.
func (h *Hub) Close() {
h.mu.Lock()
defer h.mu.Unlock()
if h.isClosed {
return
}
h.isClosed = true
close(h.closed)
h.waiters = make(map[string][]chan struct{})
}
// Generation returns the customer's current generation (0 if never bumped). Test/observability
// helper — the wait path uses the value returned by Wait directly.
func (h *Hub) Generation(customerID string) uint64 {
h.mu.Lock()
defer h.mu.Unlock()
return h.gen[customerID]
}
+246
View File
@@ -0,0 +1,246 @@
package intent
import (
"context"
"sync"
"testing"
"time"
)
// --- white-box helpers (same package) ---
func (h *Hub) waiterCount(customerID string) int {
h.mu.Lock()
defer h.mu.Unlock()
return len(h.waiters[customerID])
}
func (h *Hub) totalWaiters() int {
h.mu.Lock()
defer h.mu.Unlock()
n := 0
for _, ws := range h.waiters {
n += len(ws)
}
return n
}
// waitUntilRegistered blocks (briefly) until n waiters are registered for customerID, so tests
// don't race the goroutine that calls Wait. Fails the test on timeout.
func waitUntilRegistered(t *testing.T, h *Hub, customerID string, n int) {
t.Helper()
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
if h.waiterCount(customerID) >= n {
return
}
time.Sleep(time.Millisecond)
}
t.Fatalf("timed out waiting for %d waiter(s) on %q (have %d)", n, customerID, h.waiterCount(customerID))
}
// asyncWait runs Wait in a goroutine and returns a channel that yields its result.
func asyncWait(h *Hub, ctx context.Context, customerID string, lastSeen uint64, maxHold time.Duration) <-chan uint64 {
out := make(chan uint64, 1)
go func() { out <- h.Wait(ctx, customerID, lastSeen, maxHold) }()
return out
}
func recvWithin(t *testing.T, ch <-chan uint64, d time.Duration) uint64 {
t.Helper()
select {
case g := <-ch:
return g
case <-time.After(d):
t.Fatalf("Wait did not return within %s", d)
return 0
}
}
// --- tests ---
func TestBump_WakesRegisteredWaiter(t *testing.T) {
h := New()
ch := asyncWait(h, context.Background(), "c1", 0, time.Second)
waitUntilRegistered(t, h, "c1", 1)
h.Bump("c1")
if g := recvWithin(t, ch, time.Second); g != 1 {
t.Fatalf("expected gen 1 after one bump, got %d", g)
}
if h.totalWaiters() != 0 {
t.Fatalf("waiter leaked after bump: %d", h.totalWaiters())
}
}
// TestWait_CoalescesBurstToLatestGen is the load-bearing property (§11 Group A) with its
// RED-PROOF anchor: N rapid bumps during one hold produce ONE completion carrying the LATEST
// generation (a counter, not a per-bump queue).
//
// RED-PROOF: change Bump to complete one waiter per bump / re-register (a queue), or make the
// completion carry the FIRST bump's value instead of finish() re-reading the latest — this
// assertion (gen == N) then fails (it sees 1). Revert to restore green.
func TestWait_CoalescesBurstToLatestGen(t *testing.T) {
h := New()
ch := asyncWait(h, context.Background(), "c1", 0, 2*time.Second)
waitUntilRegistered(t, h, "c1", 1)
const n = 5
for i := 0; i < n; i++ {
h.Bump("c1")
}
if g := recvWithin(t, ch, time.Second); g != n {
t.Fatalf("expected coalesced completion with latest gen %d, got %d", n, g)
}
if h.totalWaiters() != 0 {
t.Fatalf("waiter leaked after burst: %d", h.totalWaiters())
}
if got := h.Generation("c1"); got != n {
t.Fatalf("generation counter should be %d, got %d", n, got)
}
}
// TestWait_RaceCloser_BumpBeforeWaitNotLost is the second load-bearing property with its
// RED-PROOF anchor: a bump that lands BEFORE the wait registers must not be lost — the wait
// returns immediately with the advanced generation instead of blocking to the timeout.
//
// RED-PROOF: remove the `cur != lastSeen` pre-register check in Wait — the wait then blocks the
// full maxHold and this sub-100ms assertion fails. Revert to restore green.
func TestWait_RaceCloser_BumpBeforeWaitNotLost(t *testing.T) {
h := New()
h.Bump("c1") // gen -> 1, no waiter registered yet
start := time.Now()
g := h.Wait(context.Background(), "c1", 0, 2*time.Second) // lastSeen 0 != 1 → immediate
elapsed := time.Since(start)
if g != 1 {
t.Fatalf("expected immediate gen 1, got %d", g)
}
if elapsed > 100*time.Millisecond {
t.Fatalf("race-closer should return immediately, took %s", elapsed)
}
if h.totalWaiters() != 0 {
t.Fatalf("waiter leaked on race-closer path: %d", h.totalWaiters())
}
}
func TestWait_TimeoutReturnsCurrentGenNoLeak(t *testing.T) {
h := New()
// Fresh customer, no bumps: times out returning 0.
start := time.Now()
if g := h.Wait(context.Background(), "c1", 0, 40*time.Millisecond); g != 0 {
t.Fatalf("expected 0 on timeout, got %d", g)
}
if el := time.Since(start); el < 30*time.Millisecond {
t.Fatalf("returned before maxHold elapsed (%s)", el)
}
if h.totalWaiters() != 0 {
t.Fatalf("waiter leaked after timeout: %d", h.totalWaiters())
}
// Existing generation: a same-gen wait times out returning that (unchanged) gen.
h.Bump("c2")
h.Bump("c2") // gen -> 2
if g := h.Wait(context.Background(), "c2", 2, 40*time.Millisecond); g != 2 {
t.Fatalf("expected unchanged gen 2 on timeout, got %d", g)
}
if h.totalWaiters() != 0 {
t.Fatalf("waiter leaked after same-gen timeout: %d", h.totalWaiters())
}
}
func TestWait_CtxCancelReturnsPromptly(t *testing.T) {
h := New()
ctx, cancel := context.WithCancel(context.Background())
ch := asyncWait(h, ctx, "c1", 0, 10*time.Second)
waitUntilRegistered(t, h, "c1", 1)
cancel()
if g := recvWithin(t, ch, time.Second); g != 0 {
t.Fatalf("expected 0 on ctx cancel, got %d", g)
}
if h.totalWaiters() != 0 {
t.Fatalf("waiter leaked after ctx cancel: %d", h.totalWaiters())
}
}
func TestClose_WakesAllWaitersWithCurrentGen(t *testing.T) {
h := New()
h.Bump("c1") // gen c1 -> 1
ch1 := asyncWait(h, context.Background(), "c1", 1, 10*time.Second)
ch2 := asyncWait(h, context.Background(), "c2", 0, 10*time.Second)
waitUntilRegistered(t, h, "c1", 1)
waitUntilRegistered(t, h, "c2", 1)
h.Close()
if g := recvWithin(t, ch1, time.Second); g != 1 {
t.Fatalf("c1 should return current gen 1 on close, got %d", g)
}
if g := recvWithin(t, ch2, time.Second); g != 0 {
t.Fatalf("c2 should return current gen 0 on close, got %d", g)
}
if h.totalWaiters() != 0 {
t.Fatalf("waiter leaked after close: %d", h.totalWaiters())
}
// Post-close waits return immediately.
start := time.Now()
_ = h.Wait(context.Background(), "c3", 0, 10*time.Second)
if el := time.Since(start); el > 100*time.Millisecond {
t.Fatalf("post-close Wait should return immediately, took %s", el)
}
// Post-close bumps are no-ops (must not panic on the closed channel).
h.Bump("c1")
}
func TestConcurrentWaitsSameCustomerBothComplete(t *testing.T) {
h := New()
ch1 := asyncWait(h, context.Background(), "c1", 0, 2*time.Second)
ch2 := asyncWait(h, context.Background(), "c1", 0, 2*time.Second)
waitUntilRegistered(t, h, "c1", 2)
h.Bump("c1")
if g := recvWithin(t, ch1, time.Second); g != 1 {
t.Fatalf("waiter 1 expected gen 1, got %d", g)
}
if g := recvWithin(t, ch2, time.Second); g != 1 {
t.Fatalf("waiter 2 expected gen 1, got %d", g)
}
if h.totalWaiters() != 0 {
t.Fatalf("waiter leaked: %d", h.totalWaiters())
}
}
// TestBump_EmptyCustomerIsNoop guards the global-floor path (which iterates customers) and any
// stray empty id.
func TestBump_EmptyCustomerIsNoop(t *testing.T) {
h := New()
h.Bump("")
if h.Generation("") != 0 {
t.Fatalf("empty-customer bump must be a no-op")
}
}
// TestRaceUnderStress runs Wait/Bump concurrently to shake out data races (run with -race).
func TestRaceUnderStress(t *testing.T) {
h := New()
var wg sync.WaitGroup
for i := 0; i < 20; i++ {
wg.Add(1)
go func() {
defer wg.Done()
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
defer cancel()
_ = h.Wait(ctx, "c1", 0, 80*time.Millisecond)
}()
}
for i := 0; i < 50; i++ {
h.Bump("c1")
}
wg.Wait()
if h.totalWaiters() != 0 {
t.Fatalf("waiter leaked under stress: %d", h.totalWaiters())
}
}