libp2r2p 0.11.25 → 0.11.27

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -10,7 +10,8 @@ power-ups used by that messenger.
10
10
 
11
11
  For remote-work scheduling, see [`libp2r2p/network`](network/README.md):
12
12
  `isOnline` probes connectivity and `onOnline` shares recovery monitoring,
13
- including retries when the browser omits its native `online` event.
13
+ including retries when the browser omits its native `online` event and optional
14
+ 30-second expedited windows for foreground remote sends.
14
15
 
15
16
  ## Relay reads and lifecycle
16
17
 
@@ -1251,3 +1252,8 @@ uploads budget each chunk independently. Monotonic clocks never enter storage or
1251
1252
  Nostr events. Omitted timestamps keep the publication-local delay.
1252
1253
  `RelayPool.sendEvent` accepts a consumer `signal` without closing shared sockets.
1253
1254
  See [fallback and cancellation](private-messenger/session/README.md#relay-fallback).
1255
+
1256
+ The 0.11.27 session publishes independently of local event-store persistence after
1257
+ its initial durable intent and exact-envelope checkpoint. `deliveryConfirmed`
1258
+ and the copied `localPersistence` summary distinguish relay acceptance from
1259
+ local storage. See `private-messenger/session/README.md` for retry and cleanup.
package/network/README.md CHANGED
@@ -30,3 +30,34 @@ Successful probes do not guarantee a particular relay or HTTP server is reachabl
30
30
  Handle request failures, cancellations, and timeouts separately. Browsers can
31
31
  suspend background tabs, so recovery notification has no wall-clock guarantee.
32
32
  Consumers should keep their own work queues but should not duplicate probe loops.
33
+
34
+
35
+ ## Expedited connectivity detection
36
+
37
+ `onOnline(handler, { strict = false, expediteForMs = 0, onDebug })` can request
38
+ an optional urgent window (clamped to 30,000 ms; finite, nonnegative durations).
39
+ The same realm/mode monitor coalesces subscriptions: urgent requests can advance
40
+ the next check, at least two seconds after the previous start. Failed checks
41
+ repeat at five seconds between starts, without overlapping slow checks. Native
42
+ online/focus/visibility wakeups remain available. Removing a subscription removes
43
+ its demand without stopping other listeners.
44
+
45
+ The urgent loop does not spend/reset the ordinary jittered backoff. At expiry it
46
+ returns to the ordinary deadline, or a fresh interval of the preserved retry step
47
+ if that deadline passed; it never catches up missed checks. For example, a normal
48
+ 60-second wait remains a 60-second step after an unsuccessful urgent window.
49
+ Connectivity returning after the window must await normal recovery or a new user
50
+ send/retry; automatic retries never extend urgency. `expediteForMs` is scheduling
51
+ advice, not evidence of Internet connectivity or a relay/ACK delivery deadline.
52
+
53
+ Private-message sessions remember a runtime-only 30-second deadline for new
54
+ remote intents and manual retry. Only work actually waiting for network subscribes
55
+ with the remaining time, sharing one subscription per session. Self-chat, restored
56
+ outbox entries, chunks and automatic retries never renew that deadline. Lock and
57
+ close discard urgency. A confirmed monitor result resumes the existing automatic
58
+ pause owner without another immediate probe; explicit pauses remain caller-owned.
59
+
60
+ Optional `onDebug` reports only probe reason, state, remaining window and duration.
61
+ Consumers wire it only in development; it is disabled by default. The monitor
62
+ retains no diagnostic history. Session diagnostics additionally correlate the
63
+ network wait and pause-release duration with the event ID.
package/network/index.js CHANGED
@@ -133,6 +133,8 @@ export function createConnectivityMonitor ({
133
133
  _setTimeout = globalThis.setTimeout,
134
134
  _clearTimeout = globalThis.clearTimeout,
135
135
  _random = Math.random,
136
+ _now = () => performance.now(),
137
+ onDebug,
136
138
  reportError = error => console.error('Online listener failed', error)
137
139
  } = {}) {
138
140
  if (check !== undefined && typeof check !== 'function') throw new ValidationError('INVALID_CONNECTIVITY_CHECK')
@@ -148,21 +150,49 @@ export function createConnectivityMonitor ({
148
150
  try { Promise.resolve(entry.handler()).catch(reportError) } catch (error) { reportError(error) }
149
151
  }
150
152
 
151
- function schedule (current) {
152
- if (session !== current || !listeners.size) return
153
- const delay = current.online ? 60000 : RETRY_DELAYS[Math.min(current.retry++, RETRY_DELAYS.length - 1)]
153
+ const urgencyEnd = () => Math.max(0, ...[...listeners].map(entry => entry.expediteUntil))
154
+ function arm (current) {
155
+ if (session !== current || current.pending || !listeners.size) return
156
+ if (current.timer != null) clearTimer(current.timer)
157
+ const now = _now()
158
+ const until = urgencyEnd()
159
+ const urgent = until > now && (!current.online || current.urgentDue != null)
160
+ // Urgent checks do not consume or reset the ordinary retry sequence.
161
+ if (!urgent && current.normalDue <= now) current.normalDue = now + current.normalDelay
162
+ let due = current.normalDue
163
+ if (urgent) due = Math.min(until, current.urgentDue ?? current.lastStartedAt + 5000)
154
164
  current.timer = setTimer(() => {
155
165
  current.timer = null
156
- return probe(current)
157
- }, Math.round(delay * (0.8 + _random() * 0.4)))
166
+ if (urgent && _now() >= urgencyEnd()) { arm(current); return }
167
+ return probe(current, urgent ? 'expedited' : 'backoff')
168
+ }, Math.max(0, due - now))
158
169
  current.timer?.unref?.()
159
170
  }
160
171
 
161
- async function probe (current) {
172
+ function schedule (current, expedited) {
173
+ if (session !== current || !listeners.size) return
174
+ if (!expedited || current.online) {
175
+ const delay = current.online ? 60000 : RETRY_DELAYS[Math.min(current.retry++, RETRY_DELAYS.length - 1)]
176
+ current.normalDelay = Math.round(delay * (0.8 + _random() * 0.4))
177
+ current.normalDue = _now() + current.normalDelay
178
+ }
179
+ current.urgentDue = null
180
+ arm(current)
181
+ }
182
+
183
+ async function probe (current, reason = 'wake') {
162
184
  if (session !== current || current.pending) return
163
185
  if (current.timer != null) clearTimer(current.timer)
164
186
  current.timer = null
165
187
  current.pending = true
188
+ current.lastStartedAt = _now()
189
+ const expedited = reason === 'expedited'
190
+ const startedAt = _now()
191
+ const report = values => {
192
+ const entry = { stage: 'connectivity-probe', reason, expediteRemainingMs: Math.max(0, urgencyEnd() - _now()), ...values }
193
+ for (const callback of [onDebug, ...[...listeners].map(listener => listener.onDebug)]) { try { callback?.(entry) } catch {} }
194
+ }
195
+ report({ state: 'started' })
166
196
  try {
167
197
  const online = await runCheck({ signal: current.controller.signal })
168
198
  if (session !== current) return
@@ -179,12 +209,13 @@ export function createConnectivityMonitor ({
179
209
  for (const entry of listeners) entry.delivered = false
180
210
  } finally {
181
211
  current.pending = false
182
- schedule(current)
212
+ report({ state: current.online ? 'online' : 'offline', elapsedMs: _now() - startedAt })
213
+ schedule(current, expedited)
183
214
  }
184
215
  }
185
216
 
186
217
  function start () {
187
- const current = { online: false, retry: 0, timer: null, pending: false, controller: new AbortController() }
218
+ const current = { online: false, retry: 0, timer: null, pending: false, lastStartedAt: -Infinity, normalDue: Infinity, normalDelay: 5000, urgentDue: null, controller: new AbortController() }
188
219
  session = current
189
220
  current.wake = () => { probe(current) }
190
221
  current.connectionChanged = () => {
@@ -213,18 +244,29 @@ export function createConnectivityMonitor ({
213
244
  }
214
245
 
215
246
  // Notify once on confirmed initial connectivity, then after each detected reconnection.
216
- function onOnline (handler) {
247
+ function onOnline (handler, { expediteForMs = 0, onDebug } = {}) {
217
248
  if (typeof handler !== 'function') throw new ValidationError('INVALID_ONLINE_HANDLER')
218
- const entry = { handler, delivered: false }
249
+ if (!Number.isFinite(expediteForMs) || expediteForMs < 0) throw new ValidationError('INVALID_EXPEDITE_FOR_MS')
250
+ if (onDebug !== undefined && typeof onDebug !== 'function') throw new ValidationError('INVALID_CONNECTIVITY_DEBUG')
251
+ const entry = { handler, onDebug, delivered: false, expediteUntil: _now() + Math.min(expediteForMs, 30000) }
219
252
  listeners.add(entry)
220
253
  if (!session) start()
221
254
  else {
222
255
  const current = session
223
- queueMicrotask(() => { probe(current) })
256
+ if (expediteForMs > 0 || urgencyEnd() > _now()) {
257
+ current.urgentDue = Math.max(_now(), current.lastStartedAt + 2000)
258
+ arm(current)
259
+ } else {
260
+ queueMicrotask(() => {
261
+ if (urgencyEnd() > _now()) arm(current)
262
+ else probe(current)
263
+ })
264
+ }
224
265
  }
225
266
  return () => {
226
267
  if (!listeners.delete(entry)) return
227
268
  if (!listeners.size) stop()
269
+ else arm(session)
228
270
  }
229
271
  }
230
272
 
@@ -234,12 +276,12 @@ export function createConnectivityMonitor ({
234
276
  const defaultMonitors = new Map()
235
277
 
236
278
  // Share probes, capped backoff and wake-up listeners per mode in this realm.
237
- export function onOnline (handler, { strict = false } = {}) {
279
+ export function onOnline (handler, { strict = false, expediteForMs = 0, onDebug } = {}) {
238
280
  const key = strict ? 'strict' : 'lenient'
239
281
  let monitor = defaultMonitors.get(key)
240
282
  if (!monitor) {
241
283
  monitor = createConnectivityMonitor({ strict })
242
284
  defaultMonitors.set(key, monitor)
243
285
  }
244
- return monitor.onOnline(handler)
286
+ return monitor.onOnline(handler, { expediteForMs, onDebug })
245
287
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "libp2r2p",
3
- "version": "0.11.25",
3
+ "version": "0.11.27",
4
4
  "description": "Peer-to-relay-to-peer",
5
5
  "keywords": [
6
6
  "p2r2p",
@@ -33,11 +33,14 @@ export function createPauseRecovery ({ attempt, online, onOnline, retryable, onE
33
33
  const run = () => {
34
34
  if (!active() || job.work) return job.work
35
35
  const work = (async () => {
36
- if (online && !await online({ signal: job.controller.signal })) {
36
+ const confirmedOnline = job.confirmedOnline
37
+ job.confirmedOnline = false
38
+ if (online && !confirmedOnline && !await online({ signal: job.controller.signal })) {
37
39
  if (!active()) return
38
40
  job.stopOnline = onOnline(() => {
39
41
  if (!active()) return
40
42
  job.stopOnline?.(); job.stopOnline = null
43
+ job.confirmedOnline = true
41
44
  schedule(0)
42
45
  })
43
46
  return
@@ -77,7 +77,8 @@ Callbacks are optional `onDebug`, `onOutbox`, `onMedia`,
77
77
  account permissions/connectivity/lock state to availability and owns view subscriptions. There are no DOM or UI imports.
78
78
 
79
79
  `onSendError` identifies the main outbox message even when its quote, metadata,
80
- file chunks, or local persistence failed. The original error is also reported to
80
+ file chunks, or self-chat persistence failed. Remote local-persistence failures
81
+ use onError and the copied localPersistence summary, never a send failure. The original error is also reported to
81
82
  `onError` unchanged. It fires once per failed send attempt (including retries),
82
83
  not when persisted failures are read, nor for inbox/download/deletion operations
83
84
  or cancelled/closed sends. Apps own route visibility and user-intent filtering;
@@ -233,10 +234,10 @@ creates a fresh publication lifetime, without treating the pause as a user abort
233
234
  ## Independent local persistence and publication
234
235
 
235
236
  One encrypted outbox feeds two independent serial processors. Local persistence
236
- never waits for peer preparation or an in-flight publication. Remote entries
237
- become publishable only after their local progress checkpoint commits; failed
238
- checkpoints are retried even if the copies have already committed. Text may
239
- continue passing uploads between chunks after its local stage is ready.
237
+ never waits for peer preparation or an in-flight publication. After the initial encrypted intent commits, remote preparation and publication
238
+ never wait for event-store persistence. Only the exact prepared-envelope
239
+ checkpoint gates transmission. Text continues passing uploads between chunks;
240
+ its local stage need not have completed. Both stages use the admitted inner ID.
240
241
 
241
242
  Self entries emit `status: 'saved'` as soon as local persistence succeeds. They
242
243
  do not write a redundant localSaved checkpoint before removal. Remote entries
@@ -275,7 +276,7 @@ First acceptance of every required envelope emits saved independently of archive
275
276
  or cleanup. ACK progress checkpoints serialize with other entry writes. A crash
276
277
  between ACK and its checkpoint may replay the same envelope; acknowledged durable
277
278
  progress only archives. Archive failure never becomes `onSendError` or changes
278
- saved status. Removal waits for archival and outstanding entry writes.
279
+ saved status. Removal waits for local persistence, archival and outstanding entry writes.
279
280
 
280
281
  At most one unit awaits archival per entry; a stalled upload yields its remote
281
282
  slot to other texts rather than accumulating the entire file. Cancel stops wire
@@ -283,3 +284,35 @@ work but retains potentially transmitted seeds until archival finishes. Close
283
284
  drains active local operations and leaves durable backoff work for restart.
284
285
  Diagnostics add publication/seed IDs, checkpoint time, archive wait/execution and
285
286
  first-ACK timing, without message or ciphertext contents.
287
+
288
+ ## Independent delivery status (0.11.27)
289
+
290
+ `deliveryConfirmed` is an optional boolean in encrypted outbox JSON. It records
291
+ relay confirmation separately from `localSaved`. Old entries normalize from
292
+ saved delivery status, never from local progress; their event/file indices and
293
+ prepared envelopes remain unchanged. No database migration is needed.
294
+
295
+ `onOutbox` adds `localPersistence: { state, retryable }`, where state is pending,
296
+ saved or failed. It is a presentation summary with no native error objects.
297
+ A saved remote entry may still have pending/failed local work. Keep it visible
298
+ through reload until the local store catches up. `retry(id)` on a confirmed
299
+ entry repeats local/archival cleanup only and never renews network urgency.
300
+ Recoverable local failures retain bounded backoff without clearing terminal
301
+ remote errors or releasing network pauses. Self-chat still confirms on commit.
302
+
303
+ Entry updates serialize through existing-only checkpoints; not-yet-started
304
+ writes coalesce and capture the latest monotonic progress at transaction start.
305
+ Prepared-publication callers wait for the resulting durable snapshot. An ACK
306
+ checkpoint may merge local progress but never requires it. Shutdown/lock waits
307
+ for admitted local writes; cancellation cannot recreate a removed intent.
308
+
309
+ The local failure summary remains failed while a retry is queued or running;
310
+ unrelated snapshots cannot hide its warning before commit. Retry admission is
311
+ runtime-only and temporarily makes that summary non-retryable. A failed retry
312
+ restores retryability after its operation ends; a successful commit clears the
313
+ failure. This does not change the already confirmed remote status.
314
+
315
+ Legacy single-stage failure flags with incomplete local progress normalize into
316
+ local failure state, preserving manual-only retry refusals. The new delivery
317
+ marker distinguishes those records from a modern remote failure racing local
318
+ persistence. Both formats enter the same processors after normalization.
@@ -4,13 +4,19 @@ import { isRecoverableStorageFailure } from '../../helpers/pause-recovery.js'
4
4
 
5
5
  export function createPublicationJournal ({ entries, store, archive, changed, onError, debug, setTimer = setTimeout, clearTimer = clearTimeout, random = Math.random }) {
6
6
  const writes = new Map()
7
+ const waitingWrites = new Map()
7
8
  const failed = new Set()
8
9
  const durable = new WeakSet()
9
10
  const queued = new WeakMap()
10
11
  let working; let stopped = false; let enabled = false; let timer; let delay = 1000
11
12
  function write (entry) {
13
+ if (waitingWrites.has(entry.id)) return waitingWrites.get(entry.id)
12
14
  const previous = writes.get(entry.id) || Promise.resolve()
13
- const work = previous.catch(() => {}).then(() => store().put(structuredClone(entry), { existing: true }))
15
+ const work = previous.catch(() => {}).then(() => {
16
+ waitingWrites.delete(entry.id)
17
+ return store().put(structuredClone(entry), { existing: true })
18
+ })
19
+ waitingWrites.set(entry.id, work)
14
20
  writes.set(entry.id, work)
15
21
  work.finally(() => { if (writes.get(entry.id) === work) writes.delete(entry.id) }).catch(() => {})
16
22
  return work
@@ -45,7 +45,7 @@ function messengerStatusSnapshot (value) {
45
45
  return { closed: value.closed, paused: value.paused, pauseReasons: [...value.pauseReasons] }
46
46
  }
47
47
 
48
- export function createPrivateMessageSession ({ owner, signer, eventStore, messageStorage, chunkStorage, recoveryStorage, fallbackRelays = [], fallbackDelayMs = null, mode = 'seeder', seedersForPeer = peer => [peer], allowedKinds = [5, 9, 1063, 34601], useContentKeys = true, pendingDecryptionReady = true, onMedia = () => {}, onOutbox = () => {}, onError = () => {}, onSendError = () => {}, onDebug, Messenger = createPrivateMessenger, openOutbox = createChatOutbox, FileTransfer = createPrivateFileTransfer, _onOnline = onOnline, _isOnline = isOnline, _getIykcProofs = getIykcProofs, _setTimeout = setTimeout, _clearTimeout = clearTimeout, _random = Math.random, openDownloads = options => createChatOutbox({ ...options, namespace: 'downloads' }) }) {
48
+ export function createPrivateMessageSession ({ owner, signer, eventStore, messageStorage, chunkStorage, recoveryStorage, fallbackRelays = [], fallbackDelayMs = null, mode = 'seeder', seedersForPeer = peer => [peer], allowedKinds = [5, 9, 1063, 34601], useContentKeys = true, pendingDecryptionReady = true, onMedia = () => {}, onOutbox = () => {}, onError = () => {}, onSendError = () => {}, onDebug, Messenger = createPrivateMessenger, openOutbox = createChatOutbox, FileTransfer = createPrivateFileTransfer, _onOnline = onOnline, _isOnline = isOnline, _getIykcProofs = getIykcProofs, _setTimeout = setTimeout, _clearTimeout = clearTimeout, _random = Math.random, _now = () => performance.now(), openDownloads = options => createChatOutbox({ ...options, namespace: 'downloads' }) }) {
49
49
  fallbackRelays = normalizeFallbackRelays(fallbackRelays)
50
50
  fallbackDelayMs = normalizeFallbackDelay(fallbackDelayMs)
51
51
  if (typeof pendingDecryptionReady !== 'boolean') throw new ValidationError('INVALID_PENDING_DECRYPTION_READY')
@@ -67,9 +67,11 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
67
67
  const entries = new Map()
68
68
  const cancelled = new Set()
69
69
  const attemptStarts = new Map()
70
+ const networkUrgency = new Map()
70
71
  const sendControllers = new Map()
71
- const localReady = new Set()
72
72
  const localIdleWaiters = new Set()
73
+ // Last local failure survives retry admission until an actual commit.
74
+ const localRetries = new Set()
73
75
  const cleanups = new Map()
74
76
  const queuedAt = new WeakMap()
75
77
  const debug = (entry, stage, startedAt, coordinates = {}) => {
@@ -79,6 +81,8 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
79
81
  const offlineSends = new Set()
80
82
  const pausedSends = new Set()
81
83
  let messengerStatus
84
+ let networkPauseStartedAt
85
+ let networkConfirmed = false
82
86
  let messengerBinding
83
87
  let storageBlocked = false
84
88
  const inboxRecovery = createPauseRecovery({
@@ -89,22 +93,32 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
89
93
  let inboxError
90
94
  const stateChanged = state => {
91
95
  if (closed) return
96
+ const wasNetworkPaused = messengerStatus?.pauseReasons.includes('network')
92
97
  messengerStatus = messengerStatusSnapshot(state)
98
+ if (!wasNetworkPaused && state.pauseReasons.includes('network')) { networkPauseStartedAt = _now(); networkConfirmed = false }
99
+ if (wasNetworkPaused && !state.pauseReasons.includes('network')) {
100
+ for (const entry of entries.values()) if (entry.peer !== owner && entry.status !== 'saved') debug(entry, 'network-pause-released', undefined, { elapsedMs: _now() - networkPauseStartedAt })
101
+ networkPauseStartedAt = undefined; networkConfirmed = false
102
+ }
93
103
  if (!available || state.closed) return
94
104
  if (state.paused) {
95
105
  for (const entry of entries.values()) {
96
106
  if (entry.peer !== owner && entry.failed && entry.status === 'pending') pausedSends.add(entry.id)
97
107
  }
98
- if (![...entries.values()].some(entry => pendingRetryable(entry) && !pausedSends.has(entry.id))) clearSendRetry()
108
+ if (![...entries.values()].some(entry => (entry.localFailed && entry.localRetryable) || (pendingRetryable(entry) && !pausedSends.has(entry.id)))) clearSendRetry()
109
+ watchOfflineSends()
99
110
  return
100
111
  }
101
112
  clearSendRetry()
102
113
  for (const id of pausedSends) { const entry = entries.get(id); if (entry) { entry.failed = false; attemptStarts.set(entry.id, performance.now()) } }
103
114
  pausedSends.clear()
115
+ if ([...entries.values()].some(entry => entry.localFailed && entry.localRetryable)) scheduleSendRetry()
116
+ watchOfflineSends()
104
117
  // The callback can run inside construction/resume, before configure settles.
105
118
  queueMicrotask(() => pump())
106
119
  }
107
120
  let stopOnline
121
+ let requestedUrgency = 0
108
122
  let sendRetryTimer
109
123
  let sendRetryDelay = SEND_RETRY_MIN_MS
110
124
  let messenger
@@ -141,7 +155,12 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
141
155
  let redrain = false
142
156
  const idleWaiters = new Set()
143
157
  const settled = () => { if (!sending && !localSaving && !enqueuing && !draining && !cleanups.size) { for (const resolve of idleWaiters) resolve(); idleWaiters.clear() } }
144
- const emit = () => onOutbox([...entries.values()].filter(entry => !entry.deletion && !entry.cancelled))
158
+ const localComplete = entry => entry.localSaved.every(Boolean)
159
+ const completed = entry => localComplete(entry) && (entry.peer === owner || entry.deliveryConfirmed)
160
+ const emit = () => onOutbox([...entries.values()].filter(entry => !entry.deletion && !entry.cancelled).map(entry => ({
161
+ ...entry,
162
+ localPersistence: { state: localComplete(entry) ? 'saved' : entry.localFailed ? 'failed' : 'pending', retryable: Boolean(entry.localFailed && entry.localRetryable && !localRetries.has(entry.id)) }
163
+ })))
145
164
  const journal = createPublicationJournal({
146
165
  entries, store: () => storage, onError: reportError, debug, setTimer: _setTimeout, clearTimer: _clearTimeout, random: _random,
147
166
  archive: async (entry, rows) => {
@@ -152,7 +171,8 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
152
171
  }
153
172
  },
154
173
  changed: entry => {
155
- if (!entry.prepared && (entry.status === 'saved' || entry.cancelled)) cleanupEntry(entry)
174
+ watchOfflineSends()
175
+ if (!entry.prepared && (completed(entry) || entry.cancelled)) cleanupEntry(entry)
156
176
  else if (!closed && available) pumpRemote()
157
177
  }
158
178
  })
@@ -161,11 +181,17 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
161
181
  storage = await openOutbox({ owner, signer, onDebug })
162
182
  for (const entry of await storage.list()) {
163
183
  // Old encrypted entries retain progress and enter this same pipeline.
184
+ const legacyLocalFailure = entry.deliveryConfirmed === undefined && entry.failed && !entry.localSaved.every(Boolean)
185
+ if (legacyLocalFailure) {
186
+ entry.localFailed = true; entry.localRetryable = Boolean(entry.retryable || entry.status === 'pending')
187
+ if (entry.peer !== owner) { entry.failed = false; entry.status = 'pending' }
188
+ }
189
+ entry.deliveryConfirmed ??= entry.peer !== owner && entry.status === 'saved'
190
+ if (entry.deliveryConfirmed) entry.status = 'saved'
164
191
  if (entry.cancelled) cancelled.add(entry.id)
165
192
  entries.set(entry.id, entry)
166
193
  journal.adopt(entry)
167
194
  attemptStarts.set(entry.id, performance.now())
168
- if (entry.localSaved.every(Boolean)) localReady.add(entry.id)
169
195
  queuedAt.set(entry, Date.now())
170
196
  }
171
197
  emit()
@@ -419,10 +445,12 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
419
445
  }
420
446
  async function cleanupEntry (entry) {
421
447
  if (entry.prepared) { journal.wake(); return }
448
+ if (!entry.cancelled && !completed(entry)) return
449
+ if (entry.failed && entry.status === 'saved') return
422
450
  if (cleanups.has(entry.id)) return cleanups.get(entry.id)
423
451
  const startedAt = Date.now()
424
452
  const work = Promise.resolve().then(async () => { await journal.flush(entry); await storage.remove(entry.id) }).then(() => {
425
- entries.delete(entry.id); localReady.delete(entry.id); attemptStarts.delete(entry.id)
453
+ entries.delete(entry.id); localRetries.delete(entry.id); attemptStarts.delete(entry.id)
426
454
  debug(entry, 'outbox-cleanup', startedAt)
427
455
  emit()
428
456
  }).catch(error => {
@@ -434,7 +462,49 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
434
462
  cleanups.set(entry.id, work)
435
463
  return work
436
464
  }
437
- async function sendEntry (entry, localOnly = false) {
465
+ async function saveEntry (entry) {
466
+ if (entry.peer === owner) { entry.status = 'pending'; emit() }
467
+ const releaseForeground = foreground.enter()
468
+ const active = async () => {
469
+ if (storage.has && !await storage.has(entry.id)) { cancelled.add(entry.id); sendControllers.get(entry.id)?.abort() }
470
+ if (closed || !available || cancelled.has(entry.id) || (entry.peer !== owner && !peers.has(entry.peer))) throw new Error('CHAT_UNAVAILABLE')
471
+ }
472
+ const startedAt = Date.now()
473
+ try {
474
+ debug(entry, 'local-start', queuedAt.get(entry))
475
+ for (let index = 0; index < entry.events.length; index++) {
476
+ await active()
477
+ if (entry.localSaved[index]) continue
478
+ const event = entry.events[index]
479
+ const hearsay = !event.sig && event.pubkey !== owner
480
+ const result = await messageStorage.save(event, { peerPubkey: entry.peer, hearsay })
481
+ if (result?.result?.code === 'blocked') { cancelled.add(entry.id); entry.cancelled = true; sendControllers.get(entry.id)?.abort(); await active() }
482
+ if (!result?.result?.ok) throw Object.assign(new Error('MESSAGE_STORAGE_FAILED'), { code: result?.result?.code?.toUpperCase() })
483
+ entry.localSaved[index] = true
484
+ }
485
+ entry.localFailed = false
486
+ debug(entry, 'local-stored', startedAt)
487
+ if (entry.peer === owner) entry.status = 'saved'
488
+ emit()
489
+ if (completed(entry)) cleanupEntry(entry)
490
+ else await journal.write(entry)
491
+ } catch (error) {
492
+ if (cancelled.has(entry.id)) { sendControllers.get(entry.id)?.abort(); entry.cancelled = true; cleanupEntry(entry) } else if (!closed && available) {
493
+ entry.localFailed = true; entry.localRetryable = retryable(error)
494
+ if (entry.peer === owner) { entry.status = 'error'; entry.retryable = entry.localRetryable }
495
+ await journal.write(entry).catch(reportError)
496
+ reportError(error)
497
+ if (entry.localRetryable) scheduleSendRetry()
498
+ if (entry.peer === owner && !entry.deletion) onSendError(error, { id: entry.id, peer: entry.peer })
499
+ emit()
500
+ }
501
+ } finally {
502
+ const retried = localRetries.delete(entry.id)
503
+ releaseForeground()
504
+ if (retried && entry.localFailed) emit()
505
+ }
506
+ }
507
+ async function sendEntry (entry) {
438
508
  if (entry.status === 'saved') return
439
509
  const releaseForeground = foreground.enter()
440
510
  entry.status = 'pending'; emit()
@@ -442,36 +512,9 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
442
512
  sendControllers.set(entry.id, controller)
443
513
  try {
444
514
  const active = async () => {
445
- if (storage.has && !await storage.has(entry.id)) cancelled.add(entry.id)
515
+ if (storage.has && !await storage.has(entry.id)) { cancelled.add(entry.id); sendControllers.get(entry.id)?.abort() }
446
516
  if (closed || !available || cancelled.has(entry.id) || (entry.peer !== owner && !peers.has(entry.peer))) throw new Error('CHAT_UNAVAILABLE')
447
517
  }
448
- if (localOnly) {
449
- debug(entry, 'local-start', queuedAt.get(entry))
450
- const startedAt = Date.now()
451
- for (let index = 0; index < entry.events.length; index++) {
452
- await active()
453
- if (entry.localSaved[index]) continue
454
- const event = entry.events[index]
455
- const hearsay = !event.sig && event.pubkey !== owner
456
- const result = await messageStorage.save(event, { peerPubkey: entry.peer, hearsay })
457
- if (result?.result?.code === 'blocked') { cancelled.add(entry.id); await active() }
458
- if (!result?.result?.ok) throw Object.assign(new Error('MESSAGE_STORAGE_FAILED'), { code: result?.result?.code?.toUpperCase() })
459
- entry.localSaved[index] = true
460
- }
461
- debug(entry, 'local-stored', startedAt)
462
- await active()
463
- if (entry.peer === owner) {
464
- entry.status = 'saved'; emit()
465
- cleanupEntry(entry)
466
- } else {
467
- // Retry this checkpoint even when a preceding attempt saved all copies
468
- // but failed to persist progress. Only then admit remote publication.
469
- await journal.write(entry)
470
- await active()
471
- localReady.add(entry.id)
472
- }
473
- return
474
- }
475
518
  if (entry.peer !== owner && !preparation.get(entry.peer)) {
476
519
  if (preparation.error(entry.peer)) throw preparation.error(entry.peer)
477
520
  preparation.request(entry.peer).catch(() => {})
@@ -505,14 +548,10 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
505
548
  await active()
506
549
  if (entry.prepared) { journal.accept(entry); return }
507
550
  await journal.write(entry)
508
- if ([...entries.values()].some(pending => !pending.files.length && pending.peer !== owner && !pending.failed && !localReady.has(pending.id))) {
509
- await pumpLocal()
510
- if (localSaving) await new Promise(resolve => localIdleWaiters.add(resolve))
511
- }
512
551
  let admitted = 0
513
552
  for (const pending of entries.values()) {
514
553
  if (closed || !available || admitted >= 8) break
515
- if (!localReady.has(pending.id) || pending.peer === owner || pending.status === 'saved' || pending.files.length || pending.failed || cancelled.has(pending.id) || pending.prepared?.accepted || !peers.has(pending.peer)) continue
554
+ if (pending.peer === owner || pending.status === 'saved' || pending.files.length || pending.failed || cancelled.has(pending.id) || pending.prepared?.accepted || !peers.has(pending.peer)) continue
516
555
  admitted++
517
556
  await sendEntry(pending)
518
557
  }
@@ -526,21 +565,23 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
526
565
  debug(entry, 'publication-accepted', startedAt)
527
566
  await active()
528
567
  entry.index++
529
- if (entry.index === entry.events.length) { entry.status = 'saved'; emit() }
568
+ if (entry.index === entry.events.length) { entry.deliveryConfirmed = true; entry.status = 'saved'; emit() }
530
569
  if (entry.prepared) { journal.accept(entry); return }
531
570
  if (entry.index < entry.events.length) await journal.write(entry)
532
571
  }
533
- entry.status = 'saved'; emit()
572
+ entry.deliveryConfirmed = true; entry.status = 'saved'; emit()
573
+ await journal.write(entry)
534
574
  cleanupEntry(entry)
535
575
  } catch (cause) {
536
576
  // Pauses can surface at any stage (text publication, file authorization
537
577
  // or chunk delivery). Normalize all of them into the retryable path.
538
- const error = cause?.retryWhenAvailable ? cause : (isUnavailableSend(cause) ? unavailableError(cause, controller.signal) : cause)
578
+ const error = !available || closed ? unavailableError(cause) : cause?.retryWhenAvailable ? cause : (isUnavailableSend(cause) ? unavailableError(cause, controller.signal) : cause)
539
579
  if (cancelled.has(entry.id)) {
540
580
  entry.cancelled = true
541
581
  if (entry.prepared) { await journal.write(entry); journal.wake() } else { await storage.remove(entry.id); entries.delete(entry.id) }
542
582
  } else {
543
- entry.status = error.retryWhenAvailable ? 'pending' : 'error'; entry.failed = true; entry.retryable = retryable(error)
583
+ if (!entry.deliveryConfirmed) entry.status = error.retryWhenAvailable ? 'pending' : 'error'
584
+ entry.failed = true; entry.retryable = retryable(error)
544
585
  await journal.write(entry).catch(reportError)
545
586
  if (entry.peer !== owner && error.retryWhenAvailable && messenger?.readStatus().paused) pausedSends.add(entry.id)
546
587
  else if (isUnavailableSend(cause) && cause.pauseReasons?.length && available && !closed) {
@@ -553,13 +594,13 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
553
594
  // The failed wire event may be a quote or file chunk. Report the
554
595
  // owning outbox item separately, without mutating the native error.
555
596
  if (error.retryWhenOnline) offlineSends.add(entry.id)
556
- if (entry.failed && error.retryWhenAvailable && !pausedSends.has(entry.id)) scheduleSendRetry()
557
- if (!closed && !entry.deletion && !cancelled.has(entry.id) && !error.retryWhenAvailable) onSendError(error, { id: entry.id, peer: entry.peer })
597
+ if (entry.failed && (error.retryWhenAvailable || (entry.deliveryConfirmed && entry.retryable)) && !pausedSends.has(entry.id)) scheduleSendRetry()
598
+ if (!closed && !entry.deletion && !cancelled.has(entry.id) && !entry.deliveryConfirmed && !error.retryWhenAvailable) onSendError(error, { id: entry.id, peer: entry.peer })
558
599
  }
559
600
  emit()
560
601
  } finally { sendControllers.delete(entry.id); releaseForeground() }
561
602
  }
562
- function stopOnlineRetry () { stopOnline?.(); stopOnline = undefined }
603
+ function stopOnlineRetry () { stopOnline?.(); stopOnline = undefined; requestedUrgency = 0 }
563
604
  // Transient availability failures without an active messenger pause retain
564
605
  // bounded retries. Observed pauses wait exclusively for their release.
565
606
  function clearSendRetry () {
@@ -570,7 +611,7 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
570
611
  clearSendRetry()
571
612
  sendRetryDelay = SEND_RETRY_MIN_MS
572
613
  }
573
- const pendingRetryable = entry => Boolean(entry.failed && !cancelled.has(entry.id) && (entry.retryable || entry.status === 'pending'))
614
+ const pendingRetryable = entry => Boolean(!cancelled.has(entry.id) && ((entry.localFailed && entry.localRetryable) || (entry.failed && (entry.retryable || entry.status === 'pending'))))
574
615
  function scheduleSendRetry () {
575
616
  if (closed || !available || sendRetryTimer) return
576
617
  const delay = Math.min(SEND_RETRY_MAX_MS, sendRetryDelay * (0.8 + _random() * 0.4))
@@ -580,32 +621,60 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
580
621
  if (closed || !available) return
581
622
  let retried = false
582
623
  for (const entry of entries.values()) {
583
- if (!pendingRetryable(entry) || pausedSends.has(entry.id)) continue
624
+ if (!pendingRetryable(entry)) continue
625
+ if (entry.localFailed && entry.localRetryable) { localRetries.add(entry.id); retried = true }
626
+ if (pausedSends.has(entry.id) || !entry.failed || !(entry.retryable || entry.status === 'pending')) continue
584
627
  entry.failed = false
585
628
  attemptStarts.set(entry.id, performance.now())
586
629
  if (entry.status === 'saved') { cleanupEntry(entry); continue }
587
630
  retried = true
588
631
  }
589
- if (retried) pump()
632
+ if (retried) { emit(); pump() }
590
633
  }, delay)
591
634
  sendRetryTimer.unref?.()
592
635
  }
593
636
  function watchOfflineSends () {
594
- if (closed || !available || !offlineSends.size) { stopOnlineRetry(); return }
595
- if (stopOnline) return
596
- // A fresh listener receives confirmed connectivity even when a brief outage
597
- // occurred between the app's long-lived monitor checks.
637
+ const waiting = [...entries.values()].filter(entry => entry.peer !== owner && peers.has(entry.peer) && entry.status !== 'saved' && !entry.cancelled && !cancelled.has(entry.id) && !entry.prepared?.accepted && (!entry.failed || pendingRetryable(entry)) &&
638
+ (offlineSends.has(entry.id) || (!networkConfirmed && messengerStatus?.pauseReasons.includes('network'))))
639
+ for (const id of networkUrgency.keys()) {
640
+ const entry = entries.get(id)
641
+ if (!entry || entry.status === 'saved' || entry.cancelled || cancelled.has(id)) networkUrgency.delete(id)
642
+ }
643
+ if (closed || !available || !waiting.length) { stopOnlineRetry(); return }
644
+ const deadline = Math.max(0, ...waiting.map(entry => networkUrgency.get(entry.id) ?? 0))
645
+ if (stopOnline && deadline === requestedUrgency) return
646
+ const previous = stopOnline
647
+ requestedUrgency = deadline
648
+ const startedAt = _now()
649
+ // Replacing a subscription retains the shared loop and its backoff. This is
650
+ // only done when a new user action extends the window, never per chunk/retry.
598
651
  stopOnline = _onOnline(() => {
599
652
  stopOnlineRetry()
600
653
  if (closed || !available) return
654
+ networkConfirmed = true
655
+ for (const entry of waiting) debug(entry, 'network-confirmed', undefined, { elapsedMs: _now() - startedAt })
601
656
  for (const id of offlineSends) {
602
657
  const entry = entries.get(id)
603
658
  if (entry?.retryable) { entry.failed = false; attemptStarts.set(entry.id, performance.now()) }
604
659
  }
605
660
  offlineSends.clear()
661
+ // The messenger's automatic recovery owns its network pause; the same
662
+ // monitor also notifies that recovery job. Never resume explicit pauses.
606
663
  return schedule()
664
+ }, {
665
+ expediteForMs: Math.max(0, deadline - _now()),
666
+ ...(onDebug
667
+ ? {
668
+ onDebug: measure => {
669
+ const entry = waiting[0]
670
+ debug(entry, measure.stage, undefined, measure)
671
+ }
672
+ }
673
+ : {})
607
674
  })
675
+ previous?.()
608
676
  }
677
+
609
678
  async function pumpLocal () {
610
679
  if (!available || closed || !storage) return
611
680
  if (localSaving) { relocal = true; return }
@@ -615,14 +684,16 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
615
684
  relocal = false
616
685
  for (const entry of entries.values()) {
617
686
  if (closed || !available) break
618
- if (entry.failed || cancelled.has(entry.id) || (entry.peer !== owner && !peers.has(entry.peer))) continue
619
- if (entry.status === 'saved') { cleanupEntry(entry); continue }
620
- if (localReady.has(entry.id) && entry.peer !== owner) continue
621
- await sendEntry(entry, true)
687
+ if ((entry.localFailed && !localRetries.has(entry.id)) || cancelled.has(entry.id) || (entry.peer !== owner && !peers.has(entry.peer))) continue
688
+ if (localComplete(entry) && !entry.localFailed) { if (entry.status === 'saved') cleanupEntry(entry); continue }
689
+ await saveEntry(entry)
622
690
  pumpRemote()
623
691
  }
624
692
  } while (relocal)
625
- } finally { localSaving = false; for (const resolve of localIdleWaiters) resolve(); localIdleWaiters.clear(); settled() }
693
+ } finally {
694
+ if ([...entries.values()].some(entry => (entry.localFailed && entry.localRetryable) || (entry.status === 'saved' && entry.failed && entry.retryable))) scheduleSendRetry()
695
+ localSaving = false; for (const resolve of localIdleWaiters) resolve(); localIdleWaiters.clear(); settled()
696
+ }
626
697
  }
627
698
  async function pumpRemote () {
628
699
  if (!available || closed || !storage || !messenger) return
@@ -634,7 +705,7 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
634
705
  for (const entry of entries.values()) {
635
706
  if (closed || !available) break
636
707
  if (entry.prepared?.accepted) { journal.wake(); continue }
637
- if (entry.peer === owner || entry.status === 'saved' || !localReady.has(entry.id) || !peers.has(entry.peer) || entry.failed || cancelled.has(entry.id)) continue
708
+ if (entry.peer === owner || entry.status === 'saved' || !peers.has(entry.peer) || entry.failed || cancelled.has(entry.id)) continue
638
709
  if (!preparation.get(entry.peer) && !preparation.error(entry.peer)) {
639
710
  preparation.request(entry.peer).catch(() => {})
640
711
  continue
@@ -738,6 +809,7 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
738
809
  const next = new Set([...values].filter(peer => peer !== owner))
739
810
  if (next.size === peers.size && [...next].every(peer => peers.has(peer))) return configuring
740
811
  peers.clear(); for (const peer of next) peers.add(peer)
812
+ watchOfflineSends()
741
813
  preparation.reconcile(peers)
742
814
  if (pendingDecryptionPeer && !peers.has(pendingDecryptionPeer)) {
743
815
  pendingDecryptionPeer = null
@@ -757,20 +829,27 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
757
829
  const changed = available !== (value === true)
758
830
  available = value === true
759
831
  if (!changed && !available) return
760
- stopOnlineRetry(); offlineSends.clear()
832
+ if (changed || !available) { stopOnlineRetry(); offlineSends.clear() }
833
+ if (!available) { networkUrgency.clear(); networkConfirmed = false }
761
834
  if (available) resetSendRetry()
762
835
  else clearSendRetry()
763
836
  if (changed) lifecycle++
764
837
  if (!available) {
838
+ for (const controller of sendControllers.values()) controller.abort()
765
839
  journal.setAvailable(false)
766
840
  preparation.setAvailable(false)
767
841
  for (const peer of channels.keys()) if (!preparation.get(peer)) channels.delete(peer)
768
- inboxRecovery.stop(); await messenger?.pause('signer'); return
842
+ inboxRecovery.stop(); await messenger?.pause('signer')
843
+ if (localSaving) await new Promise(resolve => localIdleWaiters.add(resolve))
844
+ return
769
845
  }
770
846
  await ready()
771
847
  journal.setAvailable(true)
772
848
  pumpLocal()
773
- for (const entry of entries.values()) if (pendingRetryable(entry)) { entry.failed = false; attemptStarts.set(entry.id, performance.now()) }
849
+ for (const entry of entries.values()) {
850
+ if (entry.localRetryable) localRetries.add(entry.id)
851
+ if (entry.failed && (entry.retryable || entry.status === 'pending')) { entry.failed = false; attemptStarts.set(entry.id, performance.now()) }
852
+ }
774
853
  if (storageBlocked) inboxRecovery.start({ immediate: true })
775
854
  if (!changed && messenger && !configurationFailed) { preparation.setAvailable(true); pump(); return configuring }
776
855
  return schedule()
@@ -778,6 +857,8 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
778
857
  async enqueue ({ peer, event, context = [], requiredFiles = [], deletion = false, attemptStartedAt }) {
779
858
  if (closed || !available || (peer !== owner && !peers.has(peer))) throw new Error('CHAT_UNAVAILABLE')
780
859
  if (attemptStartedAt !== undefined && (!Number.isFinite(attemptStartedAt) || attemptStartedAt < 0)) throw new ValidationError('INVALID_ATTEMPT_STARTED_AT')
860
+ const urgencyUntil = _now() + 30000
861
+ const urgencyLifecycle = lifecycle
781
862
  const releaseForeground = foreground.enter()
782
863
  enqueuing++
783
864
  try {
@@ -786,7 +867,7 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
786
867
  const id = getEventHash(main)
787
868
  const events = [...context.map(value => wireEvent(value, owner)), main]
788
869
  const files = events.filter(value => value.kind === 1063).map(value => ({ ...decodeFileMetadata(value), optional: !requiredFiles.includes(getEventHash(value)) })).filter(file => file.service === 'irfs').flatMap(file => [file, ...(file.thumbnail?.root ? [{ ...file.thumbnail, optional: file.optional }] : [])])
789
- const entry = { id, peer, event: { ...main, id }, events, files, index: 0, fileIndex: 0, chunkIndex: 0, localSaved: context.map(() => true).concat(false), status: 'pending', deletion }
870
+ const entry = { id, peer, event: { ...main, id }, events, files, index: 0, fileIndex: 0, chunkIndex: 0, localSaved: context.map(() => true).concat(false), status: 'pending', deliveryConfirmed: false, deletion }
790
871
  const startedAt = Date.now()
791
872
  debug(entry, 'outbox-start')
792
873
  await storage.put(entry)
@@ -797,31 +878,51 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
797
878
  // An accepted durable intent survives shutdown/locking for replay. Only
798
879
  // explicit cancellation removes it; no late callback or work is admitted.
799
880
  if (closed) return id
800
- entries.set(id, entry); emit(); pump()
881
+ entries.set(id, entry)
882
+ if (peer !== owner && available && lifecycle === urgencyLifecycle) networkUrgency.set(id, urgencyUntil)
883
+ watchOfflineSends(); emit(); pump()
801
884
  if (available && peer !== owner) preparePeer(peer).catch(() => {})
802
885
  return id
803
886
  } finally { releaseForeground(); enqueuing--; settled() }
804
887
  },
805
888
  async retry (id) {
806
- offlineSends.delete(id); pausedSends.delete(id); watchOfflineSends()
807
889
  if (storageBlocked) inboxRecovery.start({ immediate: true })
808
890
  await ready()
809
891
  const entry = entries.get(id)
810
- if (entry) { attemptStarts.set(id, performance.now()); journal.retry(); entry.failed = false; resetSendRetry(); if (entry.peer !== owner) preparePeer(entry.peer).catch(() => {}); return pump() }
892
+ if (!entry) return
893
+ journal.retry()
894
+ if (entry.deliveryConfirmed) {
895
+ // This action belongs to local recovery, never a fresh network attempt.
896
+ if (entry.localFailed && !entry.localRetryable) return
897
+ if (entry.localFailed) localRetries.add(id)
898
+ entry.failed = false
899
+ resetSendRetry(); emit()
900
+ return pumpLocal()
901
+ }
902
+ if (entry.localFailed) localRetries.add(id)
903
+ if (entry.peer !== owner && entry.status !== 'saved' && available) networkUrgency.set(id, _now() + 30000)
904
+ // Renew while the old wait is still subscribed, preserving the monitor's
905
+ // generation/backoff even if this session is its only consumer.
906
+ watchOfflineSends()
907
+ offlineSends.delete(id); pausedSends.delete(id)
908
+ attemptStarts.set(id, performance.now()); journal.retry(); entry.failed = false; resetSendRetry()
909
+ if (entry.peer !== owner) preparePeer(entry.peer).catch(() => {})
910
+ return pump()
811
911
  },
812
912
  async cancel (id) {
813
913
  cancelled.add(id)
914
+ localRetries.delete(id)
814
915
  sendControllers.get(id)?.abort()
815
916
  offlineSends.delete(id); pausedSends.delete(id); watchOfflineSends()
816
917
  await ready()
817
918
  const entry = entries.get(id)
818
- if (entry?.prepared) { entry.cancelled = true; await journal.write(entry); journal.wake() } else { entries.delete(id); localReady.delete(id); await storage.remove(id) }
919
+ if (entry?.prepared) { entry.cancelled = true; await journal.write(entry); journal.wake() } else { entries.delete(id); await storage.remove(id) }
819
920
  emit()
820
921
  },
821
922
  close () {
822
923
  return (closeWork ??= (async () => {
823
- closed = true; available = false; lifecycle++
824
- stopOnlineRetry(); offlineSends.clear(); activeDownloads.clear(); pendingPriorityRanges.clear()
924
+ closed = true; available = false; lifecycle++; localRetries.clear()
925
+ stopOnlineRetry(); offlineSends.clear(); networkUrgency.clear(); activeDownloads.clear(); pendingPriorityRanges.clear()
825
926
  clearSendRetry(); inboxRecovery.stop(); preparation.close(); foreground.close(); pausedSends.clear()
826
927
  for (const controller of sendControllers.values()) controller.abort()
827
928
  await Promise.allSettled([configuring, initialized, messengerWork, fileSetupWork])