mirror of
https://github.com/gesellix/Bose-SoundTouch.git
synced 2026-09-07 15:07:17 +00:00
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:
co-authored by
Claude Opus 5
parent
5d8db18ba4
commit
6c40d9a6cc
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user