fix(player): reconnect in place instead of reloading the page

The player reloaded itself five seconds after its status socket closed. That
cannot work while the service is down, because the document is served by that
same service: the tab left a working UI for the browser's error page, lost
whatever it held (a pending source command, the selected device, scroll
position) and stayed there until reloaded by hand.

The socket now reconnects with exponential backoff, from 1s to 15s, and a
banner says the connection was lost. The page stays usable and recovers on
its own when the service returns, with no interaction.

This is only safe because of the epoch added alongside the source-selection
work. A restarted service publishes revisions from 0 again, and revisions are
only comparable within one epoch; without it a reconnected socket would
deliver a sequence the browser rejects forever, leaving the page silently
frozen. Reloading was presumably how that was avoided before.

The regression test drives the outage through a TCP proxy it can take down
and bring back at the same address. Simulating this needs both refusing new
connections and severing established ones: a server that stops accepting
leaves an open WebSocket running, and Chrome's offline emulation does not
close it either, so neither reproduces a service that went away. Against the
old behaviour the test fails with "Inspected target navigated or closed",
which is the reload destroying the page.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Tobias Gesellchen
2026-09-05 20:57:34 +02:00
co-authored by Claude Opus 5
parent 5d8db18ba4
commit 6c40d9a6cc
3 changed files with 240 additions and 6 deletions
@@ -13,9 +13,12 @@ import (
"context"
"encoding/json"
"fmt"
"io"
"io/fs"
"net"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"sync"
"testing"
@@ -167,6 +170,164 @@ func TestPlayerRendersNatively(t *testing.T) {
// script types), which routes every browser -- including this ordinary
// headless Chrome -- through the library's own polyfill resolution instead
// of native import map support.
// outageProxy is a TCP proxy in front of the test server that can be taken
// down and brought back at the same address.
//
// Simulating an outage needs both halves: refusing new connections AND
// severing the established ones. A server that merely stops accepting leaves
// an open WebSocket running, and Chrome's offline emulation does not close it
// either, so neither reproduces a service that went away.
type outageProxy struct {
listener net.Listener
backend string
mu sync.Mutex
up bool
conns []net.Conn
}
func newOutageProxy(t *testing.T, backend string) *outageProxy {
t.Helper()
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("listen: %v", err)
}
p := &outageProxy{listener: listener, backend: backend, up: true}
t.Cleanup(func() {
_ = listener.Close()
p.setUp(false)
})
go p.serve()
return p
}
func (p *outageProxy) url() string { return "http://" + p.listener.Addr().String() }
func (p *outageProxy) serve() {
for {
client, err := p.listener.Accept()
if err != nil {
return
}
p.mu.Lock()
serving := p.up
p.mu.Unlock()
if !serving {
_ = client.Close()
continue
}
upstream, err := net.Dial("tcp", p.backend)
if err != nil {
_ = client.Close()
continue
}
p.mu.Lock()
p.conns = append(p.conns, client, upstream)
p.mu.Unlock()
go func() { _, _ = io.Copy(upstream, client) }()
go func() { _, _ = io.Copy(client, upstream) }()
}
}
// setUp brings the proxy down or back. Going down also drops every connection
// already established, which is what makes an open WebSocket notice.
func (p *outageProxy) setUp(up bool) {
p.mu.Lock()
p.up = up
conns := p.conns
p.conns = nil
p.mu.Unlock()
if up {
return
}
for _, c := range conns {
_ = c.Close()
}
}
// TestPlayerSurvivesAServiceOutage: the player used to reload itself five
// seconds after the socket closed, which cannot work while the service is
// down, since the document is served by that same service. The tab landed on
// the browser's error page and everything the page held was lost.
//
// It must now stay up, say so, and recover on its own. The epoch on each
// status is what makes that safe: a restarted service publishes revisions
// from 0 again, and without the epoch the browser would reject them forever.
func TestPlayerSurvivesAServiceOutage(t *testing.T) {
app := NewWebApp()
r := chi.NewRouter()
app.Mount(r, nil)
server := httptest.NewServer(r)
t.Cleanup(server.Close)
backendURL, err := url.Parse(server.URL)
if err != nil {
t.Fatalf("parse server URL: %v", err)
}
proxy := newOutageProxy(t, backendURL.Host)
ctx := newHeadlessChromeContext(t)
if err := chromedp.Run(ctx,
chromedp.Navigate(proxy.url()+"/app"),
chromedp.WaitVisible(`#app`, chromedp.ByQuery),
// The socket must be up before taking it away.
chromedp.Poll(`document.querySelector('.connection-banner') === null`, nil),
); err != nil {
t.Fatalf("connect before the outage: %v", err)
}
proxy.setUp(false)
var bannerAfterOutage, stillLoaded string
if err := chromedp.Run(ctx,
chromedp.Poll(`document.querySelector('.connection-banner') !== null`, nil),
chromedp.Text(`.connection-banner`, &bannerAfterOutage, chromedp.ByQuery),
// The page is still the player, not the browser's error page.
chromedp.Evaluate(`document.querySelector('#app') !== null ? 'loaded' : 'gone'`, &stillLoaded),
); err != nil {
t.Fatalf("detect the outage: %v", err)
}
if !strings.Contains(bannerAfterOutage, "Reconnecting") {
t.Errorf("banner during outage = %q, want it to say it is reconnecting", bannerAfterOutage)
}
if stillLoaded != "loaded" {
t.Error("player did not survive the outage")
}
proxy.setUp(true)
var navigations int
if err := chromedp.Run(ctx,
// Recovers on its own, with no interaction.
chromedp.Poll(`document.querySelector('.connection-banner') === null`, nil),
chromedp.Evaluate(`performance.getEntriesByType('navigation').length`, &navigations),
); err != nil {
t.Fatalf("recover after the outage: %v", err)
}
// Still the document that weathered the outage, not a reloaded one.
if navigations != 1 {
t.Errorf("navigation entries = %d, want 1: the player reloaded instead of reconnecting", navigations)
}
}
func TestPlayerRendersUnderForcedShimMode(t *testing.T) {
app := NewWebApp()
r := chi.NewRouter()
@@ -999,6 +999,30 @@ img { display: block; max-width: 100%; }
}
/* ── Toast ───────────────────────────────────────────────────────────────── */
/* ── Connection banner ───────────────────────────────────────────────────── */
.connection-banner {
position: fixed;
top: 0;
left: 0;
right: 0;
padding: .5rem 1rem;
text-align: center;
font-size: .8125rem;
z-index: 300;
box-shadow: 0 2px 8px rgba(0,0,0,.2);
/* --stale is not redefined for dark mode, so pairing it with a fixed
near-black keeps the contrast readable in both themes. */
background: var(--stale);
color: #1a1a1a;
}
/* Opening the socket is not a problem worth an alarm colour. */
.connection-banner.connecting {
background: var(--surface);
color: var(--text);
border-bottom: 1px solid var(--border);
}
.toast {
position: fixed;
bottom: 1.5rem;
+55 -6
View File
@@ -21,6 +21,10 @@ import { removeDeviceAndRefresh } from './deviceRemoval.js';
const html = htm.bind(h);
// Reconnect backoff for the status socket, doubling from base to max.
const RECONNECT_BASE_MS = 1000;
const RECONNECT_MAX_MS = 15000;
function statusRevision(status) {
const revision = status?.revision;
return Number.isSafeInteger(revision) && revision >= 0 ? revision : null;
@@ -172,6 +176,9 @@ function App() {
const [toast, setToast] = useState(null);
const [version, setVersion] = useState(null);
const [isDiscovering, setIsDiscovering] = useState(false);
// 'connecting' until the first frame arrives, so a page opened while the
// service is down does not claim the connection was lost.
const [connection, setConnection] = useState('connecting');
const getPageTitle = () => {
if (page === 'devices') return 'Devices';
@@ -208,10 +215,12 @@ function App() {
.catch(err => console.error('Failed to fetch version:', err));
const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:';
const ws = new WebSocket(`${protocol}//${location.host}/api/control/ws`);
let socket = null;
let reconnectTimer;
let backoff = RECONNECT_BASE_MS;
let closed = false;
ws.onmessage = (event) => {
const handleMessage = (event) => {
const msg = JSON.parse(event.data);
if (msg.type === 'devices') {
setDevices(previous => mergeDevicesSnapshot(previous, msg.data));
@@ -232,13 +241,45 @@ function App() {
}
};
ws.onclose = () => {
reconnectTimer = setTimeout(() => location.reload(), 5000);
};
// Reconnect in place rather than reloading. Reloading a page whose
// own document is served by the service cannot work while the service
// is down: it replaces a working UI with the browser's error page and
// loses everything the page held. Reconnecting keeps the page usable
// and recovers on its own when the service returns.
//
// This is safe because each status carries the epoch of the
// connection that produced it. A restarted service publishes
// revisions from 0 again, which the browser would otherwise reject
// forever; a newer epoch is accepted regardless of its revision, so a
// reconnected socket resynchronises without a reload.
function connect() {
if (closed) return;
const ws = new WebSocket(`${protocol}//${location.host}/api/control/ws`);
socket = ws;
ws.onopen = () => {
backoff = RECONNECT_BASE_MS;
setConnection('online');
};
ws.onmessage = handleMessage;
ws.onclose = () => {
if (closed || socket !== ws) return;
setConnection('offline');
reconnectTimer = setTimeout(connect, backoff);
backoff = Math.min(backoff * 2, RECONNECT_MAX_MS);
};
}
connect();
return () => {
closed = true;
clearTimeout(reconnectTimer);
ws.close();
socket?.close();
};
}, []);
@@ -404,6 +445,14 @@ function App() {
</footer>
` : null}
${connection !== 'online' ? html`
<div class="connection-banner ${connection}" role="status" aria-live="polite" key="connection">
${connection === 'connecting'
? 'Connecting to AfterTouch…'
: 'Lost contact with AfterTouch. Reconnecting…'}
</div>
` : null}
${toast ? html`<div class="toast" role="status" aria-live="polite"
aria-atomic="true" key="toast">${toast}</div>` : null}
</div>