From 3c3653cb5f5b4f280dd08132a868fc715a7c43f2 Mon Sep 17 00:00:00 2001 From: Cody Kandarian Date: Sun, 19 Apr 2026 08:01:22 -0700 Subject: [PATCH] =?UTF-8?q?fix(phone-quickdrop):=20make=20phone=E2=86=92la?= =?UTF-8?q?ptop=20pipeline=20reliable=20end-to-end?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three real reliability bugs plus a blocking UI issue that made the QR unscannable. Verified end-to-end on a real iPhone over USB tether. Server (server.js): - WS heartbeat: ping every 20s, terminate sockets that miss a pong. Previously idle/NAT-dropped connections sat open forever. - Resume across disconnect: on phone WS close mid-recording, keep the file open for 20s; a new WS with {type:"start", resume:true, sessionId} re-attaches and keeps appending to the same file. Prevents silent data loss when Wi-Fi blips. - QR now renders black-on-white instead of white-on-transparent — the old combo collided with the CSS override and produced an invisible QR (solid white card). Phone (public/phone.html): - onstop race fix: track in-flight ev.data.arrayBuffer() promises and drain them before sending "end". Previously the final chunk could arrive after the server closed the session and be dropped as binary_without_session. - On WS reconnect mid-recording, re-send start with resume:true + the original sessionId before flushing queued chunks, so buffered binary frames land in the original file. - Stash sessionMime/sessionExt so the resume start carries them. Dashboard (public/desktop.html): - Remove the .qr-wrap svg[fill="#ffffff"] { fill: transparent } hack that was blanking out the QR modules. Tests: - scripts/smoke-reconnect.js: opens WS, sends half the chunks, terminate()s the socket, reconnects with resume:true, sends the rest, verifies on-disk bytes byte-for-byte match the concatenation. Runs via `npm run smoke:reconnect`. Co-Authored-By: Claude Opus 4.7 (1M context) --- phone-quickdrop/package.json | 3 +- phone-quickdrop/public/desktop.html | 3 - phone-quickdrop/public/phone.html | 48 ++++- phone-quickdrop/scripts/smoke-reconnect.js | 199 +++++++++++++++++++++ phone-quickdrop/server.js | 93 +++++++++- 5 files changed, 330 insertions(+), 16 deletions(-) create mode 100644 phone-quickdrop/scripts/smoke-reconnect.js diff --git a/phone-quickdrop/package.json b/phone-quickdrop/package.json index 2e379e4..bd1dadb 100644 --- a/phone-quickdrop/package.json +++ b/phone-quickdrop/package.json @@ -7,7 +7,8 @@ "type": "commonjs", "scripts": { "start": "node server.js", - "smoke": "node scripts/smoke.js" + "smoke": "node scripts/smoke.js", + "smoke:reconnect": "node scripts/smoke-reconnect.js" }, "engines": { "node": ">=18" diff --git a/phone-quickdrop/public/desktop.html b/phone-quickdrop/public/desktop.html index 09d8c8b..fc7b1b9 100644 --- a/phone-quickdrop/public/desktop.html +++ b/phone-quickdrop/public/desktop.html @@ -76,10 +76,7 @@ .qr-wrap svg { width: 100% !important; height: 100% !important; shape-rendering: crispEdges; - color: #000; } - .qr-wrap svg rect[fill="#ffffff"], - .qr-wrap svg path[fill="#ffffff"] { fill: transparent; } .url-row { display: flex; align-items: center; gap: 8px; diff --git a/phone-quickdrop/public/phone.html b/phone-quickdrop/public/phone.html index 85264c0..88e30f9 100644 --- a/phone-quickdrop/public/phone.html +++ b/phone-quickdrop/public/phone.html @@ -276,6 +276,8 @@

Couldn't start camera

let recorder = null; let recording = false; let sessionId = null; + let sessionMime = null; + let sessionExt = null; let bytesSent = 0; let chunkCount = 0; let startedAt = 0; @@ -284,6 +286,9 @@

Couldn't start camera

let micOn = true; let wakeLock = null; let pendingChunks = []; // queued while WS not ready (rare) + // Outstanding ev.data.arrayBuffer() promises. Must drain before we send + // "end", otherwise the final chunk races the end-marker and gets dropped. + const pendingSends = new Set(); // ---------- mime negotiation ---------- function pickMimeType() { @@ -331,7 +336,21 @@

Couldn't start camera

ws.addEventListener("open", () => { wsReady = true; setConn("connected", "ready"); - // flush anything queued (very rare path) + // If we died mid-recording, ask the server to resume this sessionId + // before we flush queued chunks. WebSocket preserves order within a + // single connection, so chunks sent next will land after the resume. + if (recording && sessionId) { + try { + ws.send(JSON.stringify({ + type: "start", + sessionId, + mime: sessionMime || "application/octet-stream", + ext: sessionExt || "bin", + resume: true, + ts: startedAt, + })); + } catch { /* will close and reconnect */ } + } for (const buf of pendingChunks) ws.send(buf); pendingChunks = []; }); @@ -461,28 +480,39 @@

Couldn't start camera

sessionId = (crypto.randomUUID && crypto.randomUUID()) || ("s" + Math.random().toString(36).slice(2) + Date.now().toString(36)); + sessionMime = recorder.mimeType || mime || "application/octet-stream"; + sessionExt = ext; bytesSent = 0; chunkCount = 0; startedAt = Date.now(); + pendingSends.clear(); // Tell server a recording is starting wsSend(JSON.stringify({ type: "start", sessionId, - mime: recorder.mimeType || mime || "application/octet-stream", - ext, + mime: sessionMime, + ext: sessionExt, ts: startedAt, })); - recorder.ondataavailable = async (ev) => { + recorder.ondataavailable = (ev) => { if (!ev.data || ev.data.size === 0) return; chunkCount += 1; bytesSent += ev.data.size; - const buf = await ev.data.arrayBuffer(); - wsSend(buf); - updateSizePill(); + // Schedule the arrayBuffer conversion and remember the promise so + // onstop can drain it before sending "end". + const p = ev.data.arrayBuffer() + .then((buf) => { wsSend(buf); updateSizePill(); }) + .catch(() => { /* ignore; recorder error handler reports */ }); + pendingSends.add(p); + p.finally(() => pendingSends.delete(p)); }; - recorder.onstop = () => { + recorder.onstop = async () => { + // Drain any in-flight chunk conversions so the last chunk beats "end". + if (pendingSends.size) { + try { await Promise.allSettled([...pendingSends]); } catch {} + } wsSend(JSON.stringify({ type: "end", sessionId, @@ -490,6 +520,8 @@

Couldn't start camera

chunks: chunkCount, })); sessionId = null; + sessionMime = null; + sessionExt = null; recorder = null; }; recorder.onerror = (ev) => { diff --git a/phone-quickdrop/scripts/smoke-reconnect.js b/phone-quickdrop/scripts/smoke-reconnect.js new file mode 100644 index 0000000..7cf7ffe --- /dev/null +++ b/phone-quickdrop/scripts/smoke-reconnect.js @@ -0,0 +1,199 @@ +#!/usr/bin/env node +/** + * Reconnect smoke test. + * + * Simulates a phone whose WebSocket dies in the middle of a recording: + * 1. Open WS, send "start" with sessionId S + * 2. Send N/2 chunks + * 3. Abruptly close the socket (not a graceful "end") + * 4. Open a new WS, send "start" with same sessionId and resume:true + * 5. Expect ack { type:"started", resumed:true, bytes: setTimeout(r, ms)); } + +function connect() { + return new Promise((resolve, reject) => { + const ws = new WebSocket(URL, { rejectUnauthorized: false }); + ws.once("open", () => resolve(ws)); + ws.once("error", reject); + }); +} + +function collectMessages(ws) { + const messages = []; + ws.on("message", (data, isBinary) => { + if (isBinary) return; + try { messages.push(JSON.parse(data.toString())); } catch {} + }); + return messages; +} + +async function waitFor(messages, type, timeoutMs = 2000) { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + const m = messages.find((x) => x.type === type); + if (m) return m; + await sleep(10); + } + return null; +} + +async function main() { + console.log(`smoke-reconnect: connecting to ${URL}`); + + // Pre-generate all chunks so we know exactly what bytes should land on disk. + const chunks = []; + for (let i = 0; i < CHUNK_COUNT; i++) { + chunks.push(crypto.randomBytes(CHUNK_BYTES)); + } + const expectedBuffer = Buffer.concat(chunks); + + const sessionId = "reconnect-" + crypto.randomBytes(3).toString("hex"); + const label = "reconnect-test"; + + // ---- phase 1: open, start, send half, kill ---- + let ws = await connect(); + let msgs = collectMessages(ws); + + ws.send(JSON.stringify({ + type: "start", + sessionId, + mime: "video/mp4", + ext: "mp4", + label, + ts: Date.now(), + })); + + const started1 = await waitFor(msgs, "started", 2000); + if (!started1 || started1.resumed) { + console.error(`smoke-reconnect: FAIL — expected fresh 'started' ack, got ${JSON.stringify(started1)}`); + process.exit(2); + } + const fileName = started1.fileName; + console.log(`smoke-reconnect: started ${sessionId} → ${fileName}`); + + for (let i = 0; i < CHUNK_COUNT / 2; i++) { + ws.send(chunks[i]); + await sleep(15); + } + + // Give the server a beat to flush the binary frames to disk before we cut + // the socket — otherwise we're testing TCP close semantics, not resume. + await sleep(80); + + // Abrupt close — terminate, not close() — so server sees an unclean drop. + ws.terminate(); + console.log(`smoke-reconnect: killed WS after ${CHUNK_COUNT / 2} chunks`); + + // Wait briefly for server to register the close and orphan the session. + await sleep(200); + + // ---- phase 2: reconnect with resume ---- + ws = await connect(); + msgs = collectMessages(ws); + + ws.send(JSON.stringify({ + type: "start", + sessionId, + mime: "video/mp4", + ext: "mp4", + resume: true, + ts: Date.now(), + })); + + const started2 = await waitFor(msgs, "started", 2000); + if (!started2) { + console.error("smoke-reconnect: FAIL — no 'started' ack after resume"); + process.exit(3); + } + if (!started2.resumed) { + console.error(`smoke-reconnect: FAIL — expected resumed:true, got ${JSON.stringify(started2)}`); + process.exit(4); + } + const halfBytes = (CHUNK_COUNT / 2) * CHUNK_BYTES; + if (typeof started2.bytes !== "number" || started2.bytes < 1 || started2.bytes > halfBytes) { + console.error( + `smoke-reconnect: FAIL — resume bytes unreasonable: got ${started2.bytes}, expected 1..${halfBytes}` + ); + process.exit(5); + } + console.log( + `smoke-reconnect: resumed ${sessionId} (server already has ${started2.bytes} bytes)` + ); + + // ---- phase 3: send the rest ---- + for (let i = CHUNK_COUNT / 2; i < CHUNK_COUNT; i++) { + ws.send(chunks[i]); + await sleep(15); + } + ws.send(JSON.stringify({ + type: "end", + sessionId, + totalBytes: TOTAL, + })); + + const saved = await waitFor(msgs, "saved", 3000); + ws.close(); + if (!saved) { + console.error("smoke-reconnect: FAIL — no 'saved' ack"); + process.exit(6); + } + + // ---- phase 4: verify on-disk bytes ---- + const filePath = path.join(OUTPUT_DIR, fileName); + if (!fs.existsSync(filePath)) { + console.error(`smoke-reconnect: FAIL — file missing: ${filePath}`); + process.exit(7); + } + const onDisk = fs.readFileSync(filePath); + if (onDisk.length !== TOTAL) { + console.error( + `smoke-reconnect: FAIL — size mismatch: disk=${onDisk.length} expected=${TOTAL}` + ); + process.exit(8); + } + if (!onDisk.equals(expectedBuffer)) { + // Find first divergence for debug + let i = 0; + while (i < onDisk.length && onDisk[i] === expectedBuffer[i]) i++; + console.error( + `smoke-reconnect: FAIL — content mismatch (first diff at byte ${i})` + ); + process.exit(9); + } + + try { fs.unlinkSync(filePath); } catch {} + + console.log( + `smoke-reconnect: OK — ${TOTAL.toLocaleString()} bytes landed correctly across a mid-session disconnect.` + ); +} + +main().catch((err) => { + console.error("smoke-reconnect: error", err); + process.exit(1); +}); diff --git a/phone-quickdrop/server.js b/phone-quickdrop/server.js index 0473827..35d4b6f 100644 --- a/phone-quickdrop/server.js +++ b/phone-quickdrop/server.js @@ -43,6 +43,8 @@ const OUTPUT_DIR = process.env.QUICKDROP_OUT || path.join(os.homedir(), "Desktop", "PhoneCaptures"); const RECENT_LIMIT = 50; +const HEARTBEAT_MS = 20_000; +const ORPHAN_RESUME_MS = 20_000; fs.mkdirSync(OUTPUT_DIR, { recursive: true }); fs.mkdirSync(CERT_DIR, { recursive: true }); @@ -148,6 +150,10 @@ function safeLabel(input) { const observers = new Set(); const phones = new Set(); const recentRecordings = []; // newest first +// Sessions whose phone WS dropped mid-recording; a reconnect with the same +// sessionId + resume:true re-attaches and keeps writing to the same file. +// Key: sessionId → { session, timer } +const orphanedSessions = new Map(); function broadcastToObservers(obj) { const payload = JSON.stringify(obj); @@ -243,12 +249,13 @@ async function start() { const phoneUrl = `https://${phoneHost}:${PORT}/phone`; const dashUrl = `https://localhost:${PORT}/`; - // Pre-render QR as inline SVG. + // Pre-render QR as inline SVG — standard black-on-white so any phone + // camera can read it against the white card background. const qrSvg = await QRCode.toString(phoneUrl, { type: "svg", errorCorrectionLevel: "M", margin: 1, - color: { dark: "#ffffff", light: "#00000000" }, + color: { dark: "#000000", light: "#ffffff" }, }); // Read dashboard template once at boot, substitute. @@ -359,6 +366,38 @@ async function start() { pushPhoneStatus(); console.log(`[${peer}] phone connected`); } + + // Resume path: phone reconnected mid-recording. Re-attach to the + // orphaned session so chunks keep landing in the same file. + if (msg.resume && msg.sessionId) { + const orphan = orphanedSessions.get(msg.sessionId); + if (orphan) { + clearTimeout(orphan.timer); + orphanedSessions.delete(msg.sessionId); + if (session && !session.closed && session !== orphan.session) { + await session.finish("abort"); + } + session = orphan.session; + session.peerLabel = peer; + console.log( + `[${peer}] resume ${session.sessionId} → ${session.fileName} ` + + `(have ${session.bytes} bytes so far)` + ); + ack({ + type: "started", + resumed: true, + sessionId: session.sessionId, + fileName: session.fileName, + bytes: session.bytes, + }); + return; + } + // Orphan expired or never existed — fall through to fresh start. + console.log( + `[${peer}] resume requested but session ${msg.sessionId} unknown; starting fresh` + ); + } + if (session && !session.closed) await session.finish("abort"); session = new RecordingSession({ sessionId: msg.sessionId || crypto.randomUUID(), @@ -377,6 +416,13 @@ async function start() { if (msg.type === "end") { if (!session) { ack({ type: "error", reason: "end_without_start" }); return; } const expected = msg.totalBytes; + // If this session was previously orphaned and is now finishing, make + // sure we aren't leaving a dangling timer. + const orphanEntry = orphanedSessions.get(session.sessionId); + if (orphanEntry) { + clearTimeout(orphanEntry.timer); + orphanedSessions.delete(session.sessionId); + } await session.finish("ok"); ack({ type: "saved", @@ -390,7 +436,15 @@ async function start() { return; } if (msg.type === "abort") { - if (session) { await session.finish("abort"); session = null; } + if (session) { + const orphanEntry = orphanedSessions.get(session.sessionId); + if (orphanEntry) { + clearTimeout(orphanEntry.timer); + orphanedSessions.delete(session.sessionId); + } + await session.finish("abort"); + session = null; + } ack({ type: "aborted" }); return; } @@ -401,7 +455,20 @@ async function start() { ws.on("close", async () => { if (session && !session.closed) { - await session.finish("partial"); + // Keep the file open for a window so a reconnect can resume. If the + // phone doesn't come back, finalize as partial and emit to observers. + const sid = session.sessionId; + const s = session; + const timer = setTimeout(async () => { + if (orphanedSessions.get(sid)?.session === s) { + orphanedSessions.delete(sid); + if (!s.closed) await s.finish("partial"); + } + }, ORPHAN_RESUME_MS); + orphanedSessions.set(sid, { session: s, timer }); + console.log( + `[${peer}] session ${sid} orphaned (resumable for ${ORPHAN_RESUME_MS}ms, ${s.bytes} bytes)` + ); session = null; } if (role === "phone") { @@ -417,9 +484,27 @@ async function start() { console.error(`[${peer}] ws error:`, err.message); }); + // Heartbeat: ping every HEARTBEAT_MS; if a ping is unanswered by the next + // cycle, the socket is terminated and normal close handling kicks in + // (which orphans any active session so reconnect can resume). + ws.isAlive = true; + ws.on("pong", () => { ws.isAlive = true; }); + ack({ type: "hello", outputDir: OUTPUT_DIR, phoneUrl }); }); + const heartbeatTimer = setInterval(() => { + for (const client of wss.clients) { + if (client.isAlive === false) { + try { client.terminate(); } catch {} + continue; + } + client.isAlive = false; + try { client.ping(); } catch {} + } + }, HEARTBEAT_MS); + wss.on("close", () => clearInterval(heartbeatTimer)); + server.on("error", (err) => { if (err.code === "EADDRINUSE") { console.error(`\nPort ${PORT} is already in use. Set PORT= and try again.\n`);