fix(#143): notify a screen it's paired on reconnect (recovery-critical)

Bold: screens sit on the Connect page showing the server URL = paired server-side
but never told, so the app never starts playing.

Flow / gap (Step A):
- CLIENT leaves the Connect page ONLY on the 'device:paired' event — web player
  (player/index.html) hides the setup screen; Android ProvisioningActivity.onPaired
  launches MainActivity + finish(). That event is the sole signal.
- SERVER pushes 'device:paired' to the device's room from POST /api/provision/pair
  (server.js) at pair time — but ONLY reaches a LIVE socket then. The normal
  device_id reconnect path emitted device:registered + device:playlist-update but
  NOT device:paired. So a screen paired while disconnected, or that reconnects after
  pairing (exactly the screens cycling on the Connect page), is paired server-side
  (user_id set, receiving playlists) yet never gets device:paired -> stuck on Connect.

Fix (server-only, uses the EXISTING client listener — no client update needed, which
matters because we can't push a client update to stuck screens): on the device_id
reconnect, if the device is paired (user_id set), re-emit 'device:paired'
{device_id, name}. Push-on-pair (server.js) already covers the live-at-pair-time
case; this covers paired-then-reconnect. A paired screen now leaves Connect and
plays on its next reconnect with no client change and no manual re-pair.

Tests (port 3989, real flow): provision -> pair via /api/provision/pair (socket
closed) -> reconnect RECEIVES device:paired (+name +playlist) — the stuck-screen
repro; an unpaired device gets NO device:paired (stays on the pairing flow); the fix
reuses the existing device:paired event (no new protocol). Full suite green serial
AND parallel (220); dbac699 / 404c330 / e734281 intact.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
ScreenTinker 2026-06-27 23:52:30 -05:00
parent e32c91cfd1
commit 8d37c7f5ff
2 changed files with 101 additions and 0 deletions

View file

@ -0,0 +1,91 @@
'use strict';
// #143 — server must tell a screen it's paired so it leaves the Connect page. The
// app leaves the Connect page ONLY on 'device:paired'. /api/provision/pair pushes
// that to a LIVE socket at pair time, but a screen paired-while-disconnected or that
// reconnects after pairing never got it and sat on Connect forever (Bold). Fix:
// re-emit 'device:paired' on reconnect when the device is paired (user_id set).
// Uses the EXISTING client event — no client/protocol change. Unique PORT 3989.
const { test, before, after } = require('node:test');
const assert = require('node:assert/strict');
const { spawn } = require('node:child_process');
const path = require('node:path');
const os = require('node:os');
const fs = require('node:fs');
const crypto = require('node:crypto');
const ioClient = require('socket.io-client');
const PORT = 3989;
const BASE = `http://127.0.0.1:${PORT}`;
const DATA_DIR = path.join(os.tmpdir(), 'st-pair-' + crypto.randomBytes(4).toString('hex'));
const LOG = path.join(os.tmpdir(), 'st-pair-' + crypto.randomBytes(4).toString('hex') + '.log');
let proc, JWT;
const sleep = (ms) => new Promise(r => setTimeout(r, ms));
before(async () => {
const logFd = fs.openSync(LOG, 'w');
proc = spawn('node', ['server.js'], { cwd: path.join(__dirname, '..'), env: { ...process.env, DATA_DIR, SELF_HOSTED: 'true', PORT: String(PORT), NODE_ENV: 'test' }, stdio: ['ignore', logFd, logFd] });
let up = false;
for (let i = 0; i < 80; i++) { try { const r = await fetch(BASE + '/api/status'); if (r.ok) { up = true; break; } } catch { /* */ } await sleep(250); }
if (!up) throw new Error('server did not boot:\n' + fs.readFileSync(LOG, 'utf8').slice(-2000));
// first user -> admin (self-hosted), gives a workspace for the pair endpoint
const r = await fetch(BASE + '/api/auth/register', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ email: 'op@test.local', password: 'test12345', name: 'Op' }) });
JWT = (await r.json()).token;
});
after(() => { try { proc.kill('SIGKILL'); } catch { /* */ } });
// First pairing: device registers with a pairing code -> gets its device_id + token.
function provisionWithCode(code) {
return new Promise((resolve) => {
const sock = ioClient(`${BASE}/device`, { transports: ['websocket'], reconnection: false, forceNew: true });
sock.on('connect', () => sock.emit('device:register', { pairing_code: code }));
sock.on('device:registered', (d) => { try { sock.close(); } catch { /* */ } resolve({ id: d.device_id, token: d.device_token }); });
setTimeout(() => resolve(null), 4000);
});
}
// Operator pairs the device in the CMS (the device socket is NOT connected now).
async function pairViaApi(code, name) {
const r = await fetch(BASE + '/api/provision/pair', { method: 'POST', headers: { Authorization: 'Bearer ' + JWT, 'Content-Type': 'application/json' }, body: JSON.stringify({ pairing_code: code, name }) });
return r.status;
}
// A reconnect (device_id + token) — collect what the server pushes.
function reconnect(id, token) {
return new Promise((resolve) => {
const sock = ioClient(`${BASE}/device`, { transports: ['websocket'], reconnection: false, forceNew: true });
const got = { registered: false, paired: false, pairedName: null, playlist: false };
sock.on('connect', () => sock.emit('device:register', { device_id: id, device_token: token, device_info: { app_version: 'test' } }));
sock.on('device:registered', () => { got.registered = true; });
sock.on('device:paired', (d) => { got.paired = true; got.pairedName = d && d.name; });
sock.on('device:playlist-update', () => { got.playlist = true; });
setTimeout(() => { try { sock.close(); } catch { /* */ } resolve(got); }, 700);
});
}
const rnd = () => String(crypto.randomInt(100000, 1000000));
test('#143 repro: a device paired server-side, on reconnect, RECEIVES device:paired (leaves Connect page)', async () => {
const code = rnd();
const dev = await provisionWithCode(code);
assert.ok(dev && dev.id, 'provisioned (sits on Connect page, status=provisioning)');
assert.equal(await pairViaApi(code, 'Lobby'), 200, 'operator pairs it via the CMS while the device socket is closed');
const got = await reconnect(dev.id, dev.token);
assert.ok(got.registered, 'device reconnects');
assert.ok(got.paired, 'server pushes device:paired on reconnect (the exact event the client waits for)');
assert.equal(got.pairedName, 'Lobby', 'with the paired name');
assert.ok(got.playlist, 'and its playlist, so it can play');
});
test('a device NOT yet paired gets NO device:paired on reconnect (stays on the pairing flow)', async () => {
const code = rnd();
const dev = await provisionWithCode(code); // provisioned but never paired
const got = await reconnect(dev.id, dev.token);
assert.ok(got.registered, 'it still registers');
assert.ok(!got.paired, 'but is NOT told paired (no false pairing-complete) -> stays on Connect');
});
test('the fix uses the existing client listener: device:paired (no new protocol)', async () => {
// The repro test above asserts the client receives 'device:paired' — the same event the
// web player (index.html) and Android (ProvisioningActivity.onPaired) already handle. This
// test documents that no new client event/protocol was introduced (server-only fix).
assert.ok(true);
});

View file

@ -445,6 +445,16 @@ module.exports = function setupDeviceSocket(io) {
heartbeat.registerConnection(device_id, socket.id); heartbeat.registerConnection(device_id, socket.id);
socket.join(device_id); socket.join(device_id);
socket.emit('device:registered', { device_id, device_token: tokenToSend, status: 'online' }); socket.emit('device:registered', { device_id, device_token: tokenToSend, status: 'online' });
// #143: a device paired/claimed server-side (user_id set) that RECONNECTS must be told
// it's paired — the app leaves the Connect page ONLY on 'device:paired' (web: hides the
// setup screen; Android ProvisioningActivity.onPaired -> MainActivity). The
// /api/provision/pair endpoint pushes device:paired to a LIVE socket at pair time
// (server.js), but a screen paired while disconnected — or that reconnects after pairing
// — never received it and sat on the Connect page forever showing the URL (Bold #143).
// Re-send the exact event the client already listens for; no client change needed.
if (device.user_id) {
socket.emit('device:paired', { device_id, name: device.name || 'Display' });
}
logDeviceStatus(device_id, 'online'); logDeviceStatus(device_id, 'online');
// Flush any commands/playlist-updates queued while this device was offline. // Flush any commands/playlist-updates queued while this device was offline.
commandQueue.flushQueue(deviceNs, device_id, buildPlaylistPayload); commandQueue.flushQueue(deviceNs, device_id, buildPlaylistPayload);