zebra-report: delivery ACKs + outbox (hold sent msgs until confirmed)

This commit is contained in:
russell@unturf.com 2026-05-28 12:00:30 -04:00
parent db382f6d44
commit 90d5966c08
No known key found for this signature in database

View file

@ -382,6 +382,10 @@
<input type="text" id="msg-in" placeholder="type a message and press enter">
<button id="btn-send" disabled>send</button>
</div>
<div id="outbox-wrap" style="display:none; margin-top:0.4rem">
<p class="note" style="margin-bottom:0.2rem">outbox — awaiting delivery confirmation:</p>
<div class="peer-list" id="outbox"></div>
</div>
</section>
<!-- ============================================================ -->
@ -481,6 +485,7 @@ const T_OFFER = 0x01;
const T_READY = 0x02;
const T_DATA = 0x03;
const T_HELLO = 0x04;
const T_ACK = 0x05;
const HS_FRAME_LEN = 6;
const PBKDF2_SALT = new TextEncoder().encode('zebra-report-v1');
@ -729,6 +734,22 @@ async function buildHelloFrame(handle, maxBaud) {
out[body.length+2] = (crc >> 16) & 0xFF; out[body.length+3] = (crc >> 24) & 0xFF;
return out;
}
/* ACK frame: magic(2) type(1) sid(4) ackCrc(4) crc(4) = 15 bytes.
* ackCrc is the CRC32 of the DATA frame being acknowledged (its message id). */
async function buildAckFrame(ackCrc) {
const sid = await senderIdBytes();
const body = new Uint8Array(11);
body[0] = HS_MAGIC[0]; body[1] = HS_MAGIC[1]; body[2] = T_ACK;
body.set(sid, 3);
body[7] = ackCrc & 0xFF; body[8] = (ackCrc >> 8) & 0xFF;
body[9] = (ackCrc >> 16) & 0xFF; body[10] = (ackCrc >>> 24) & 0xFF;
const crc = crc32(body);
const out = new Uint8Array(15);
out.set(body);
out[11] = crc & 0xFF; out[12] = (crc >> 8) & 0xFF;
out[13] = (crc >> 16) & 0xFF; out[14] = (crc >>> 24) & 0xFF;
return out;
}
function parseFrame(bytes) {
if (bytes.length < 3) return null;
if (bytes[0] !== HS_MAGIC[0] || bytes[1] !== HS_MAGIC[1]) return null;
@ -748,7 +769,15 @@ function parseFrame(bytes) {
const frameNoCrc = bytes.slice(0, 9 + len);
const got = bytes[9+len] | (bytes[9+len+1] << 8) | (bytes[9+len+2] << 16) | (bytes[9+len+3] << 24);
if ((got >>> 0) !== crc32(frameNoCrc)) return { type, error: 'crc' };
return { type, sid, payload };
return { type, sid, payload, crc: (got >>> 0) };
}
if (type === T_ACK) {
if (bytes.length < 15) return null;
const sid = bytes.slice(3, 7);
const ackCrc = (bytes[7] | (bytes[8] << 8) | (bytes[9] << 16) | (bytes[10] << 24)) >>> 0;
const got = (bytes[11] | (bytes[12] << 8) | (bytes[13] << 16) | (bytes[14] << 24)) >>> 0;
if (got !== crc32(bytes.slice(0, 11))) return { type, error: 'crc' };
return { type, sid, ackCrc };
}
if (type === T_HELLO) {
if (bytes.length < 10) return null;
@ -968,6 +997,12 @@ class FrameAssembler {
this.buf.splice(0, need);
return frame;
}
if (t === T_ACK) {
if (this.buf.length < 15) return null;
const frame = new Uint8Array(this.buf.slice(0, 15));
this.buf.splice(0, 15);
return frame;
}
if (t === T_HELLO) {
if (this.buf.length < 10) return null;
const hlen = this.buf[9];
@ -1116,6 +1151,52 @@ if (LOOPBACK) {
};
}
/* ============================================================== *
* reliable delivery: outbox (pending until ACK) + retransmit *
* ============================================================== */
const msgPending = new Map(); /* frameCrc -> { handle, text, frame, tries, failed, timer } */
const rxSeenCrc = new Set(); /* recently received DATA crcs, for de-dup */
const MAX_TX_TRIES = 5;
function frameCrcOf(f) {
const n = f.length;
return (f[n-4] | (f[n-3] << 8) | (f[n-2] << 16) | (f[n-1] << 24)) >>> 0;
}
/* rough on-air time for a frame of byteLen bytes at the current link settings */
function estimateTxMs(byteLen) {
const ticks = 10 + byteLen * (2 + ZEBRA_SYMS_PER_BYTE) + 5; /* preamble + frame + trailer */
return ticks / ZEBRA_BAUD_HANDSHAKE * 1000 + 80;
}
function renderOutbox() {
const wrap = $('outbox-wrap'), el = $('outbox');
if (!wrap || !el) return;
if (msgPending.size === 0) { wrap.style.display = 'none'; el.innerHTML = ''; return; }
wrap.style.display = '';
el.innerHTML = '';
for (const [, m] of msgPending) {
const row = document.createElement('div');
row.className = 'peer';
const status = m.failed ? '✗ no ack (giving up)'
: (m.tries > 1 ? `sending… (try ${m.tries})` : 'sending…');
row.innerHTML = `<span style="color:${m.failed ? '#b00' : '#888'}">`
+ `⧗ ${escapeHtml(m.text)} — ${status}</span>`;
el.appendChild(row);
}
}
function scheduleRetransmit(crc) {
const entry = msgPending.get(crc);
if (!entry) return;
const wait = estimateTxMs(entry.frame.length) + estimateTxMs(15) + 4000; /* data + ack + margin */
entry.timer = setTimeout(async () => {
const e = msgPending.get(crc);
if (!e) return; /* already ACKed */
if (e.tries >= MAX_TX_TRIES) { e.failed = true; renderOutbox(); return; }
e.tries++; renderOutbox();
try { await txFrame(e.frame, ZEBRA_BAUD_HANDSHAKE); } catch (_) {}
scheduleRetransmit(crc);
}, wait);
}
/* ============================================================== *
* frame handler *
* ============================================================== */
@ -1152,6 +1233,17 @@ async function onRxFrame(bytes) {
if (!seen) await announceHello();
return;
}
if (f.type === T_ACK) {
const entry = msgPending.get(f.ackCrc);
if (entry) {
if (entry.timer) clearTimeout(entry.timer);
msgPending.delete(f.ackCrc);
logLine('me', `<span class="from">${escapeHtml(entry.handle)}:</span> `
+ `${escapeHtml(entry.text)} <span style="color:#060">✓</span>`);
renderOutbox();
}
return;
}
if (f.type === T_DATA) {
const sid = hex(f.sid);
let pt = null;
@ -1165,6 +1257,12 @@ async function onRxFrame(bytes) {
}
if (pt === null) { logLine('err', `rx: data from ${sid.slice(0,8)} (no key)`); return; }
}
/* received + decrypted OK → ACK it (even duplicates, so a retransmit stops
* when an earlier ACK was lost) */
try { await txFrame(await buildAckFrame(f.crc), ZEBRA_BAUD_HANDSHAKE); } catch (_) {}
if (rxSeenCrc.has(f.crc)) return; /* duplicate: re-ACKed, don't re-display */
rxSeenCrc.add(f.crc);
if (rxSeenCrc.size > 300) rxSeenCrc.clear();
const handle = (peers.get(sid) || {}).handle || sid.slice(0,8);
logLine('peer', `<span class="from">${escapeHtml(handle)}:</span> ${escapeHtml(pt)}`);
}
@ -1684,25 +1782,28 @@ async function sendCurrentMsg() {
if (mode === 'pubkey' && !Array.from(peers.values()).some(p => p.sharedKey)) {
logLine('err', 'no pubkey peers ready'); return;
}
/* must match the inbound decoder, which stays at ZEBRA_BAUD_HANDSHAKE for the
* life of the link — data sent at any other baud won't decode on the far side. */
/* data travels at ZEBRA_BAUD_HANDSHAKE to match the inbound decoder */
const baud = ZEBRA_BAUD_HANDSHAKE;
const handle = handleIn.value.trim() || 'me';
/* echo immediately so the sender sees the message without waiting for the
* (slow, multi-second at low baud) transmit to finish */
logLine('me', `<span class="from">${escapeHtml(handle)}:</span> ${escapeHtml(msg)}`);
$('msg-in').value = '';
/* don't echo to the log yet — hold in the outbox until the far side ACKs,
* retransmitting until then. the message moves into the log on its ACK. */
try {
const frames = [];
if (mode === 'passphrase') {
const ct = await aesEncrypt(groupKey, msg);
await txFrame(await buildDataFrame(ct), baud);
frames.push(await buildDataFrame(await aesEncrypt(groupKey, msg)));
} else {
for (const p of peers.values()) {
if (!p.sharedKey) continue;
const ct = await aesEncrypt(p.sharedKey, msg);
await txFrame(await buildDataFrame(ct), baud);
if (p.sharedKey) frames.push(await buildDataFrame(await aesEncrypt(p.sharedKey, msg)));
}
}
for (const frame of frames) {
const crc = frameCrcOf(frame);
msgPending.set(crc, { handle, text: msg, frame, tries: 1, failed: false, timer: null });
renderOutbox();
await txFrame(frame, baud);
scheduleRetransmit(crc);
}
} catch (e) {
logLine('err', 'send failed: ' + e.message);
}