chat.html: delivery ACKs + outbox (hold sent msgs until confirmed)

Add a reliable-delivery layer over the unreliable audio modem. A sent message
is held in an outbox (not echoed to the log) and retransmitted until the far
side ACKs it, then it moves into the log marked delivered. The DATA frame's
existing CRC32 is the message id; a new 15-byte T_ACK frame echoes it back.
Receiver ACKs every valid+decrypted frame (incl. duplicates, so a lost ACK
still stops the sender) and de-dupes on CRC so retransmits never double-show.
This commit is contained in:
Russell Ballestrini 2026-05-28 12:00:28 -04:00
parent 90a207b8c8
commit b4bfb33cf7
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);
}