From 1e6fb6005948231cb56ecc071576df23b5d7b0be Mon Sep 17 00:00:00 2001 From: Adam Soltys Date: Mon, 31 Aug 2026 08:54:35 -0700 Subject: The mailbox cursor stops leaping wounded messages: it advances only after a message's work is DONE (a final rejection counts, a transient failure does not), holds at the lowest failed checkpoint so a healthy message behind a sick one can't skip it, and the poll defers the rest of a batch on failure — one esplora hiccup mid-validateVtxo used to skip an arkoor receive forever (monogram's 204 sat, twice) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01MrkLsNScXe9b6BviUSdmMr --- src/ark/manager.js | 33 +++++++++++++++++++++++++++++++-- 1 file changed, 31 insertions(+), 2 deletions(-) (limited to 'src') diff --git a/src/ark/manager.js b/src/ark/manager.js index 492906d..b5c2aac 100644 --- a/src/ark/manager.js +++ b/src/ark/manager.js @@ -276,7 +276,14 @@ export class ArkManager { const mailbox = this._mailboxKey(); const { messages } = await readMailbox(this.arkUrl, mailbox, this.state.mailboxCheckpoint); let changed = false; - for (const m of messages) changed = (await this._processMailboxMessage(m)) || changed; + for (const m of messages) { + // A transiently-failing message stops the batch: processing the ones + // behind it would advance the cursor past it (see the hold logic in + // _processMailboxMessage) — the next poll re-reads from where it + // stands, so nothing behind the failure is lost either. + try { changed = (await this._processMailboxMessage(m)) || changed; } + catch (e) { console.warn('ark: mailbox message deferred —', e.message); break; } + } // Everything the first catch-up found is history, not news. if (baselining) { this.state.receiveAckTs = Date.now(); @@ -298,9 +305,31 @@ export class ArkManager { // Handle one mailbox message (from a poll or the live stream). Returns // whether state changed; the caller saves. Duplicate-safe: vtxos dedupe by // id and the checkpoint only moves forward. + // + // The cursor moves only AFTER the message's work is done — a final + // rejection counts as done, a transient failure does not. Advancing first + // meant one esplora hiccup mid-validation skipped a coin forever: the + // throw aborted adoption, the cursor had already moved in memory, any + // later save persisted it, and every future read started past the + // message (monogram's 204-sat receive sat invisible for two days). And a + // stuck message is never leapt: later messages may process fine, but the + // cursor holds at the lowest failed checkpoint until it clears — else a + // healthy message right behind the sick one would skip it just the same. async _processMailboxMessage(m) { + try { + const changed = await this._handleMailboxMessage(m); + if (this._mailboxStuckAt === m.checkpoint) this._mailboxStuckAt = null; + if (this._mailboxStuckAt != null && m.checkpoint > this._mailboxStuckAt) return changed; + if (m.checkpoint > this.state.mailboxCheckpoint) { this.state.mailboxCheckpoint = m.checkpoint; return true; } + return changed; + } catch (e) { + if (this._mailboxStuckAt == null || m.checkpoint < this._mailboxStuckAt) this._mailboxStuckAt = m.checkpoint; + throw e; + } + } + + async _handleMailboxMessage(m) { let changed = false; - if (m.checkpoint > this.state.mailboxCheckpoint) { this.state.mailboxCheckpoint = m.checkpoint; changed = true; } if (m.kind === 'lnSendFinished') { const a = this.state.actions.find((x) => x.type === 'ln-pay' && x.paymentHash === m.paymentHash && !['done', 'failed'].includes(x.step)); -- cgit v1.2.3