aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-10-09 08:48:06 -0700
committerAdam Soltys <[email protected]>2026-10-09 08:48:06 -0700
commite1e6665910f77009d61947528dcf4a0a528e03fc (patch)
tree31091ca88ed010c58cd4938b5bec02d91057e2d8
parent8d5786239837710683fec1b7b49868fb133e22cc (diff)
downloadcoinosv3-e1e6665910f77009d61947528dcf4a0a528e03fc.tar.gz
coinosv3-e1e6665910f77009d61947528dcf4a0a528e03fc.zip
DMs: a linked nostr login whose signer hadn't resumed yet (a reload before the bunker reconnected, a session just dropped) counted as "every key is here", so each wrap sealed to the login npub was tried on the wallet key alone, given up after ~40 s and logged as opened — never asked for again (a phone kept 9 of 186 messages). Since b06bbb4 the check only passes with no login or a live signer that decrypts (or Amber); a drain without one waits for the signer instead of re-running the wallet key, and the slow retry is rescheduled in finally so an early return can't end it. "All promises were rejected" (every relay refused the bunker request) and "not open anymore" count as try-later, not "not ours". Heal v2 re-opens every wrap the relays still hold, marked done only once nothing it re-opened is still waiting for the signer
Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01MgAFLnkF7KrdhuyCLMNgtL
-rw-r--r--src/dm.js6
-rw-r--r--src/features/messages.js53
2 files changed, 48 insertions, 11 deletions
diff --git a/src/dm.js b/src/dm.js
index c610aa3..443dd22 100644
--- a/src/dm.js
+++ b/src/dm.js
@@ -121,4 +121,8 @@ export async function unwrapDM(wrap, skOrSigner) {
return null;
}
}
-export const SIGNER_SILENT = /did not answer|timed? ?out|connect|closed|socket|relay|network/i;
+// Also silent: a bunker request every relay refused to carry ("All promises
+// were rejected" — rate-limited on one, PoW demanded by another) and a call
+// that reached a signer object selfHealing had just closed ("not open
+// anymore"). Neither is the signer saying "not yours".
+export const SIGNER_SILENT = /did not answer|timed? ?out|connect|closed|socket|relay|network|not open|all promises were rejected/i;
diff --git a/src/features/messages.js b/src/features/messages.js
index 38e533b..5702198 100644
--- a/src/features/messages.js
+++ b/src/features/messages.js
@@ -587,8 +587,13 @@ export function messagesFeature(ctx) {
// the log calls them opened: caches written before rows kept file fields,
// or clobbered by another tab, are missing messages no relay would ever be
// asked for again. Only messages and reactions are re-applied.
- const HEAL_VERSION = 1;
- let healSince = 0;
+ // v2 (2026-10-09): every wrap the relays still hold. Until then a login
+ // signer that hadn't resumed yet counted as "complete", so a boot's whole
+ // backfill could be given up on the wallet key alone and logged as opened.
+ // The pass is only marked done once nothing it re-opened is still waiting
+ // for the signer — a session closed before the bunker answered runs it again.
+ const HEAL_VERSION = 2;
+ let healSince = 0, healAt = 0, healTimer = 0;
const healed = new Set();
function loadSeenWraps() {
if (wrapLog) return;
@@ -597,11 +602,23 @@ export function messagesFeature(ctx) {
for (const k of wrapLog) seenWraps.add(k);
healSince = 0;
if ((s.dmHeal || 0) < HEAL_VERSION) {
- if (wrapLog.length) healSince = Math.floor(Date.now() / 1000) - 14 * 86400;
- s.dmHeal = HEAL_VERSION;
- save(s);
+ if (wrapLog.length) {
+ healSince = 1; healAt = Date.now();
+ // wraps that open at once never reach the drain: check on a timer too
+ clearTimeout(healTimer);
+ const tick = () => { healDone(); if (healSince) healTimer = setTimeout(tick, 60_000); };
+ healTimer = setTimeout(tick, 90_000);
+ } else { s.dmHeal = HEAL_VERSION; save(s); }
}
}
+ function healDone() {
+ if (!healSince || Date.now() - healAt < 60_000 || !decryptorsComplete()) return;
+ for (const p of pendingWraps.values()) if (p.heal) return;
+ const s = st();
+ s.dmHeal = HEAL_VERSION;
+ save(s);
+ healSince = 0;
+ }
const healing = (wrap) => !!healSince && wrap.created_at >= healSince && !healed.has(wrap.id) && !!healed.add(wrap.id);
function rememberWrap(id) {
const k = wrapKey(id);
@@ -2863,9 +2880,17 @@ export function messagesFeature(ctx) {
// only count against a wrap once the login signer (if one is linked) has
// actually had its chance — the wallet key alone can never open a wrap
// sealed to the login npub.
+ //
+ // A linked login whose signer is NOT live (a reload before the bunker
+ // resumed, or a session selfHealing just dropped) is incomplete, not
+ // "nothing to wait for": counting those passes gave every wrap sealed to
+ // the login npub up after ~40 s with only the wallet key tried, and the
+ // wrap log then hid it from every later session (a phone kept 9 of 186
+ // messages). An interactive signer (Amber) is live from the moment it
+ // resumes and never decrypts here, so it counts as complete.
function decryptorsComplete() {
const login = hook('nostrLoginIdentity');
- return !login || !login.signer || !!login.signer.interactive || !!login.signer.decryptFrom;
+ return !login || !!(login.signer && (login.signer.interactive || login.signer.decryptFrom));
}
// Raw-key unwraps (seed-derived / pasted nsec — the common case) run in
@@ -2961,8 +2986,11 @@ export function messagesFeature(ctx) {
// touch a screen that calls identity(). Resume single-flights and
// rate-limits itself, so asking here is cheap.
if (!decryptorsComplete()) await Promise.resolve(hook('nostrLoginResume')).catch(() => null);
- if (!dmDecryptors().length) return;
+ // Still no login signer: a pass would only re-run the wallet key, which
+ // already had its try when each wrap arrived. Wait for the signer
+ // (nostrSignerLive or the slow retry below) instead.
const complete = decryptorsComplete();
+ if (!complete || !dmDecryptors().length) return;
// Each openInboxWrap decrypts a gift wrap — NIP-44 ECDH, real
// secp256k1 work. A backlog decrypted in one tight loop blocks the main
// thread for the whole burst (the boot-time jank that made a carousel
@@ -2983,9 +3011,13 @@ export function messagesFeature(ctx) {
chunkStart = performance.now();
}
}
- } finally { draining = false; }
- // still holding wraps: the signer may connect later — keep a slow retry
- if (pendingWraps.size) scheduleDrain(decryptorsComplete() ? 30_000 : 60_000);
+ } finally {
+ draining = false;
+ // still holding wraps: the signer may connect later — keep a slow retry
+ // (in finally: an early return above must not end the retries)
+ if (pendingWraps.size) scheduleDrain(decryptorsComplete() ? 30_000 : 60_000);
+ healDone();
+ }
}
function openDirectBundle(json) {
@@ -13405,6 +13437,7 @@ export function messagesFeature(ctx) {
wn = null; wnLoading = null; wnReadied = false;
wnFolds.clear(); wnReach.clear(); wnReactOf.clear(); wnCands.clear();
seenWraps.clear(); wrapLog = null; // the next account must decrypt wraps this one couldn't
+ clearTimeout(healTimer); healSince = 0;
clearTimeout(drainTimer);
pendingWraps.clear();
dmStarted = false;