| index cc19441..9f27c58 100644 |
| --- a/internal/tunnel/tunnel_test.go |
| +++ b/internal/tunnel/tunnel_test.go |
| @@ -2,11 +2,13 @@ package tunnel |
| |
| import ( |
| "context" |
| + "fmt" |
| "io" |
| "net/http" |
| "net/http/httptest" |
| "net/url" |
| "strings" |
| + "sync" |
| "testing" |
| "time" |
| |
| @@ -191,6 +193,132 @@ func TestTunnelConnCap(t *testing.T) { |
| } |
| } |
| |
| +// startTunnelPair boots a backend serving handler, the given gateway, and a |
| +// client tunnelling between them, returning the gateway server and the public |
| +// slug host once it's live. Everything tears down with the test. |
| +func startTunnelPair(t *testing.T, gw *Gateway, handler http.HandlerFunc) (gwSrv *httptest.Server, host string) { |
| + t.Helper() |
| + backend := httptest.NewServer(handler) |
| + t.Cleanup(backend.Close) |
| + gwSrv = httptest.NewServer(gw) |
| + t.Cleanup(gwSrv.Close) |
| + u, _ := url.Parse(gwSrv.URL) |
| + gw.ControlHost = u.Hostname() |
| + |
| + ctx, cancel := context.WithCancel(context.Background()) |
| + t.Cleanup(cancel) |
| + cl := &Client{Gateway: gwSrv.URL, LocalAddr: strings.TrimPrefix(backend.URL, "http://")} |
| + ready := make(chan string, 1) |
| + clientDone := make(chan error, 1) |
| + go func() { clientDone <- cl.Run(ctx, func(h string) { ready <- h }) }() |
| + select { |
| + case host = <-ready: |
| + case err := <-clientDone: |
| + t.Fatalf("client exited early: %v", err) |
| + case <-time.After(5 * time.Second): |
| + t.Fatal("no ready") |
| + } |
| + return gwSrv, host |
| +} |
| + |
| +// hostRewriteTransport addresses requests to the slug host through the |
| +// gateway's test listener. |
| +func hostRewriteTransport(host string) http.RoundTripper { |
| + rt := http.DefaultTransport.(*http.Transport).Clone() |
| + return roundTripperFunc(func(r *http.Request) (*http.Response, error) { |
| + r.Host = host |
| + return rt.RoundTrip(r) |
| + }) |
| +} |
| + |
| +// versionThen serves the probe's /api/version and hands everything else to |
| +// next — the minimum for a backend to pass the Eleven door check. |
| +func versionThen(next http.HandlerFunc) http.HandlerFunc { |
| + return func(w http.ResponseWriter, r *http.Request) { |
| + if r.URL.Path == "/api/version" { |
| + w.Header().Set("Content-Type", "application/json") |
| + io.WriteString(w, `{"build":"test","version":"test-1"}`) |
| + return |
| + } |
| + next(w, r) |
| + } |
| +} |
| + |
| +// A first-load burst wider than the per-tunnel connection cap queues for a |
| +// free connection instead of failing. The no-build shell fetches 40+ modules |
| +// in one burst over HTTP/2, and a single 502 permanently fails the page's ES |
| +// module graph — this is what made `lchat try` unusable in a browser. |
| +func TestTunnelBurstQueuesBeyondConnCap(t *testing.T) { |
| + gw := &Gateway{Domain: "test.local", DialTimeout: 5 * time.Second, MaxConns: 4} |
| + gwSrv, host := startTunnelPair(t, gw, versionThen(func(w http.ResponseWriter, r *http.Request) { |
| + time.Sleep(20 * time.Millisecond) // hold connections open so the burst overlaps |
| + io.WriteString(w, "module") |
| + })) |
| + |
| + httpc := &http.Client{Transport: hostRewriteTransport(host)} |
| + var wg sync.WaitGroup |
| + codes := make([]int, 24) |
| + for i := range codes { |
| + wg.Add(1) |
| + go func() { |
| + defer wg.Done() |
| + resp, err := httpc.Get(fmt.Sprintf("%s/static/m%d.js", gwSrv.URL, i)) |
| + if err != nil { |
| + codes[i] = -1 |
| + return |
| + } |
| + io.Copy(io.Discard, resp.Body) |
| + resp.Body.Close() |
| + codes[i] = resp.StatusCode |
| + }() |
| + } |
| + wg.Wait() |
| + for i, c := range codes { |
| + if c != http.StatusOK { |
| + t.Fatalf("request %d: status %d — a burst above the cap must queue, not fail", i, c) |
| + } |
| + } |
| +} |
| + |
| +// A visitor abandoning a request mid-dial (reload, closed tab) fails that one |
| +// dial only — it must never drop the tunnel for everyone else. |
| +func TestTunnelSurvivesAbandonedRequest(t *testing.T) { |
| + closed := make(chan string, 1) |
| + gw := &Gateway{Domain: "test.local", DialTimeout: 5 * time.Second} |
| + gw.OnClose = func(host string) { closed <- host } |
| + gwSrv, host := startTunnelPair(t, gw, versionThen(func(w http.ResponseWriter, r *http.Request) { |
| + io.WriteString(w, "ok") |
| + })) |
| + |
| + gw.mu.Lock() |
| + tun := gw.tunnels[host] |
| + gw.mu.Unlock() |
| + if tun == nil { |
| + t.Fatal("no live tunnel") |
| + } |
| + |
| + abandoned, cancel := context.WithCancel(context.Background()) |
| + cancel() |
| + if conn, err := gw.dialBack(abandoned, tun); err == nil { |
| + conn.Close() // the dial-back can win the race; the tunnel must survive either way |
| + } |
| + |
| + select { |
| + case h := <-closed: |
| + t.Fatalf("abandoned request dropped the whole tunnel (%s)", h) |
| + default: |
| + } |
| + httpc := &http.Client{Transport: hostRewriteTransport(host)} |
| + resp, err := httpc.Get(gwSrv.URL + "/after") |
| + if err != nil { |
| + t.Fatalf("tunnel unusable after an abandoned request: %v", err) |
| + } |
| + defer resp.Body.Close() |
| + if resp.StatusCode != http.StatusOK { |
| + t.Fatalf("tunnel unusable after an abandoned request: HTTP %d", resp.StatusCode) |
| + } |
| +} |
| + |
| type roundTripperFunc func(*http.Request) (*http.Response, error) |
| |
| func (f roundTripperFunc) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) } |