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 +7 -1
- package/network/README.md +31 -0
- package/network/index.js +55 -13
- package/package.json +1 -1
- package/private-messenger/helpers/pause-recovery.js +4 -1
- package/private-messenger/session/README.md +39 -6
- package/private-messenger/session/helpers/publication-journal.js +7 -1
- package/private-messenger/session/index.js +172 -71
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
|
-
|
|
152
|
-
|
|
153
|
-
|
|
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
|
-
|
|
157
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
@@ -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
|
-
|
|
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
|
|
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.
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
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(() =>
|
|
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
|
|
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
|
-
|
|
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);
|
|
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
|
|
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 (
|
|
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'
|
|
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(
|
|
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)
|
|
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
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
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.
|
|
619
|
-
if (entry.status === 'saved')
|
|
620
|
-
|
|
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 {
|
|
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' || !
|
|
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')
|
|
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())
|
|
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)
|
|
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)
|
|
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);
|
|
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])
|