diff --git a/pkg/service/soundtouchweb/browser_compatibility_test.go b/pkg/service/soundtouchweb/browser_compatibility_test.go index 0e2ef098..d557802e 100644 --- a/pkg/service/soundtouchweb/browser_compatibility_test.go +++ b/pkg/service/soundtouchweb/browser_compatibility_test.go @@ -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() diff --git a/pkg/service/soundtouchweb/static/css/app.css b/pkg/service/soundtouchweb/static/css/app.css index c426cb51..5be3bb9a 100644 --- a/pkg/service/soundtouchweb/static/css/app.css +++ b/pkg/service/soundtouchweb/static/css/app.css @@ -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; diff --git a/pkg/service/soundtouchweb/static/js/app.js b/pkg/service/soundtouchweb/static/js/app.js index 11fd7022..3b2c6154 100644 --- a/pkg/service/soundtouchweb/static/js/app.js +++ b/pkg/service/soundtouchweb/static/js/app.js @@ -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() { ` : null} + ${connection !== 'online' ? html` +
+ ` : null} + ${toast ? html`