eleven / messenger Public

server: tunnel gateway — queue first-load bursts, survive abandoned requests (#1135)

A browser's first paint of the no-build shell fetches 40+ JS modules in one
HTTP/2 burst — wider than the tunnel's 32-connection dial-back cap, so the
overflow 502'd and the failed ES module imports left every `lchat try` trial
stuck on its boot spinner (reproduced live: 45 concurrent fetches → 31×200 +
14×502). The proxy transport now bounds MaxConnsPerHost just under the cap so
excess requests queue for a free connection instead of erroring, with a
matching idle pool so finished requests hand their connections to queued ones.

Also: the dial control-write is now bounded by the tunnel's own life instead
of the visitor's request context — the websocket library abandons the whole
connection on a mid-frame cancel, so one visitor reloading mid-load could
tear the trial down for everyone.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell e0c41bb9f9e480fbf8062bb36c1658cda47d75a7 parent 6f66b53
3 files changed, +153 −3
  • changelog/2026-07-19-try-first-load.md +6 −0
  • internal/tunnel/gateway.go +19 −3
  • internal/tunnel/tunnel_test.go +128 −0
diff --git a/changelog/2026-07-19-try-first-load.md b/changelog/2026-07-19-try-first-load.md
new file mode 100644
index 0000000..6af9e4d
--- /dev/null
+++ b/changelog/2026-07-19-try-first-load.md
@@ -0,0 +1,6 @@
+🤖 Trying Eleven from your own machine (`lchat try`) actually works in a
+browser now. The trial address used to lose part of the app on first load —
+the page would sit on its loading screen forever — because the tunnel turned
+away the burst of files a browser asks for all at once. It now takes the
+burst in stride, and one visitor giving up mid-load no longer cuts the trial
+off for everyone else.
diff --git a/internal/tunnel/gateway.go b/internal/tunnel/gateway.go
index a675ab7..a2489d6 100644
--- a/internal/tunnel/gateway.go
+++ b/internal/tunnel/gateway.go
@@ -281,13 +281,23 @@ func (g *Gateway) handleConn(w http.ResponseWriter, r *http.Request) {
// the control channel: every new backend connection is one "dial" message and
// one dialled-back WebSocket.
func (g *Gateway) proxyFor(t *liveTunnel) *httputil.ReverseProxy {
+ // A browser's first paint of the no-build shell is a burst of 40+ module
+ // fetches over HTTP/2 — wider than the per-tunnel dial-back cap. Bounding
+ // the transport just under the cap makes the overflow queue for a free
+ // connection (as browsers always did against HTTP/1.1's 6-connection
+ // limit) instead of erroring into 502s, any one of which permanently
+ // fails the page's ES module graph. Two slots stay free for the gateway's
+ // own liveness probes, which dial back outside this transport; the idle
+ // pool matches so a finished request hands its connection to a queued one.
+ perHost := max(1, g.maxConns()-2)
transport := &http.Transport{
DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) {
return g.dialBack(ctx, t)
},
- MaxIdleConns: 8,
+ MaxConnsPerHost: perHost,
+ MaxIdleConns: perHost,
IdleConnTimeout: 60 * time.Second,
- MaxIdleConnsPerHost: 8,
+ MaxIdleConnsPerHost: perHost,
}
return &httputil.ReverseProxy{
Rewrite: func(pr *httputil.ProxyRequest) {
@@ -323,7 +333,13 @@ func (g *Gateway) dialBack(ctx context.Context, t *liveTunnel) (net.Conn, error)
delete(g.pending, id)
g.mu.Unlock()
}
- if err := writeJSON(ctx, t.control, ControlMsg{Type: "dial", ID: id}); err != nil {
+ // The dial message rides the shared control channel, so its write is
+ // bounded by the tunnel's own life, never by one visitor's request ctx:
+ // the websocket library abandons the whole connection when a write is
+ // cancelled mid-frame, which would let a single reload mid-load kill the
+ // trial for everyone (writeJSON supplies its own 10s bound). A write
+ // error therefore really does mean the control channel is dead.
+ if err := writeJSON(context.Background(), t.control, ControlMsg{Type: "dial", ID: id}); err != nil {
cleanup()
release()
g.drop(t) // control channel is dead — the tunnel is gone
diff --git a/internal/tunnel/tunnel_test.go b/internal/tunnel/tunnel_test.go
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) }