From 43a35d0fe6abc56b6594015ae1028842c04a2c19 Mon Sep 17 00:00:00 2001 From: Adam Soltys Date: Sun, 25 Oct 2026 07:46:55 -0700 Subject: A closed wallet's NWC payments fit iOS's ten-second wake: a paid send is never marked for revocation by a second wake, lookups report outgoing sends and finish them, and pushes no longer run side by side PullCap's night of 2026-10-24: every wake's diary stopped 8-12 s after its push (iOS stops the worker there, not at 30 s). Two wakes two seconds apart both drove one send; the second's InitiateLightningPayment was refused ("state: ln-spent") and the paid send went to 'revoking', where every later wake spent its whole budget on it, so no request was answered after midnight. - manager: an initiate refused as ln-spent follows the payment ('initiated'); a send in 'revoking' asks the ASP first (paid: settle; in flight before expiry: back to waiting; not found: revoke) - worker: 9 s budget on Apple endpoints (27 s elsewhere) for answers, earlier sends and renewals; every ASP step held to it - lookup_invoice asks every copy of the connection and drives an outgoing send in flight, answering settled with the preimage - a lease in the worker's IndexedDB store serialises pushes (the Web Lock didn't); diary notes a missing Web Locks API - the reply and the mirror save run together; stuck sends are finished before a new payment only when they hold its coins Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QjVvE4YAcK3AGwV7kZXeN4 --- src/sw-nwc.js | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) (limited to 'src/sw-nwc.js') diff --git a/src/sw-nwc.js b/src/sw-nwc.js index 78bb2bd..3a48cff 100644 --- a/src/sw-nwc.js +++ b/src/sw-nwc.js @@ -6,7 +6,7 @@ import { respondFromBg, tendRenewals, tendLnPays } from './nwc-respond.js'; import { withRequestLock, withWorkLock } from './nwc-lock.js'; -import { appendBgLog } from './nwc-bg.js'; +import { appendBgLog, takeLease } from './nwc-bg.js'; import { bgAutoWithdraw } from './bg-autowithdraw.js'; import { allInboxes, classifyDm, shouldNotifyDm, classifyCall } from './dm-inbox.js'; @@ -15,7 +15,7 @@ const NOTIFIER = 'https://nwcpush.coinos.io'; // written, so a worker the phone stops mid-push leaves its last step behind. const stamp = () => new Date().toISOString().replace('T', ' ').slice(0, 19); const diary = (m) => { console.log('[sw-nwc]', m); appendBgLog(`${stamp()} ${m}`); }; -diary('worker started'); +diary(`worker started${typeof navigator !== 'undefined' && navigator.locks ? '' : ' (no Web Locks)'}`); const CALL_CACHE = 'coinos-call'; // a call offer a push brought, for the app the tap opens // where a call's offer wrap is fetched from: the notifier, or (the app served // on this machine, as the tests do) this origin @@ -179,6 +179,11 @@ self.addEventListener('push', (e) => { // four payments, no line for the fourth, then no more wakes). On an // Apple endpoint the stand-in goes up FIRST; the outcome replaces it. const apple = await applePush(); + // What a push may take. iOS stops the worker about ten seconds in (PullCap's + // diary of 2026-10-24: every wake's last line 8–12 s after its push, in the + // middle of a payment), whatever the waitUntil — so there everything is + // planned inside nine; elsewhere the usual thirty. + const budgetEnd = startedAt + (apple ? 9_000 : 27_000); const dropStandIn = async () => { const open = await self.registration.getNotifications({ tag: 'coinos-bg' }).catch(() => []); for (const n of open) n.close(); @@ -204,10 +209,10 @@ self.addEventListener('push', (e) => { // has run, cover coins without one. After the answer, inside what is // left of the push budget. const tend = async () => { - const late = await tendLnPays({ deadline: startedAt + 27_000, log: diary }).catch((err) => { diary('earlier sends failed: ' + (err && err.message)); return []; }); + const late = await tendLnPays({ deadline: budgetEnd, log: diary }).catch((err) => { diary('earlier sends failed: ' + (err && err.message)); return []; }); for (const n of late) await notice(n); if (late.length && apple) await dropStandIn(); - await tendRenewals({ deadline: startedAt + 27_000, log: (m) => diary('renew: ' + m) }) + await tendRenewals({ deadline: budgetEnd, log: (m) => diary('renew: ' + m) }) .catch((err) => diary('renewals failed: ' + (err && err.message))); }; // an NWC wake-up may also have moved money in; check the rule after @@ -217,10 +222,11 @@ self.addEventListener('push', (e) => { await withWorkLock(async () => { const waited = Date.now() - startedAt; if (waited > 500) diary(`waited ${waited} ms for an earlier push to finish`); + if (Date.now() > budgetEnd - 2_000) { diary('no time left after waiting — leaving it to the next try'); return; } let handled = false; try { handled = await withRequestLock(data.event && data.event.id, - () => respondFromBg(data, { notifier: NOTIFIER, log: diary }), + () => respondFromBg(data, { notifier: NOTIFIER, log: diary, deadline: budgetEnd }), () => { diary('another context holds this request'); return true; }); } catch (err) { diary('auto-answer failed: ' + (err && err.message)); @@ -243,6 +249,7 @@ self.addEventListener('push', (e) => { await tend(); return; } + if (Date.now() > budgetEnd) { diary('out of time — leaving it to the next try'); return; } // A kind-21001 offer request is money coming IN (someone asking us to // mint an invoice) — "an app wants to pay" would read as the opposite. const incoming = data.event && data.event.kind === 21001; @@ -257,7 +264,7 @@ self.addEventListener('push', (e) => { }); if (apple) await dropStandIn(); await tend(); - }); + }, { lease: () => takeLease('work', { wait: Math.max(0, budgetEnd - Date.now() - 3_000) }) }); diary(`push done in ${Date.now() - startedAt} ms`); }); }); -- cgit v1.2.3