@novasamatech/statement-store 0.8.7-0 → 0.8.7-2
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/dist/adapter/inMemory.d.ts +32 -0
- package/dist/adapter/inMemory.js +56 -0
- package/dist/index.d.ts +4 -1
- package/dist/index.js +2 -1
- package/dist/session/priority.d.ts +1 -0
- package/dist/session/priority.js +22 -0
- package/dist/session/priority.spec.d.ts +1 -0
- package/dist/session/priority.spec.js +43 -0
- package/dist/session/session.d.ts +7 -1
- package/dist/session/session.js +384 -134
- package/dist/session/session.spec.js +943 -346
- package/dist/session/statementProver.d.ts +3 -0
- package/dist/session/statementProver.js +20 -4
- package/dist/session/statementProver.spec.d.ts +1 -0
- package/dist/session/statementProver.spec.js +71 -0
- package/dist/session/types.d.ts +12 -0
- package/package.json +3 -3
package/dist/session/session.js
CHANGED
|
@@ -1,38 +1,127 @@
|
|
|
1
|
-
import { createExpiryFromDuration } from '@novasamatech/sdk-statement';
|
|
2
1
|
import { nanoid } from 'nanoid';
|
|
3
2
|
import { ResultAsync, err, errAsync, fromPromise, fromThrowable, ok, okAsync } from 'neverthrow';
|
|
4
3
|
import { toHex } from 'polkadot-api/utils';
|
|
4
|
+
import { Struct, str } from 'scale-ts';
|
|
5
|
+
import { ExpiryTooLowError } from '../adapter/types.js';
|
|
5
6
|
import { khash, stringToBytes } from '../crypto.js';
|
|
6
7
|
import { nonNullable, toError } from '../helpers.js';
|
|
7
8
|
import { createSessionId } from '../model/session.js';
|
|
8
9
|
import { DecodingError, DecryptionError, UnknownError } from './error.js';
|
|
9
10
|
import { toMessage } from './messageMapper.js';
|
|
11
|
+
import { nextExpiry } from './priority.js';
|
|
10
12
|
import { StatementData } from './scale/statementData.js';
|
|
11
|
-
const DEFAULT_EXPIRY_DURATION_SECS = 7 * 24 * 60 * 60; // 7 days
|
|
12
13
|
const DEFAULT_MAX_REQUEST_SIZE = 4096;
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
14
|
+
// Rejection reason shared by dispose() and the disposed guards on submit*, so a torn-down session
|
|
15
|
+
// always fails new and in-flight work the same way.
|
|
16
|
+
const SESSION_DISPOSED = 'Session disposed';
|
|
17
|
+
// Bounded retry for transient transport failures (the spec mandates retrying queries
|
|
18
|
+
// and submit_statement on connection failure). The TS adapter doesn't expose connection
|
|
19
|
+
// state, so we approximate with a short fixed backoff and an attempt cap.
|
|
20
|
+
const MAX_INIT_RETRIES = 3;
|
|
21
|
+
const MAX_SUBMIT_RETRIES = 3;
|
|
22
|
+
const RETRY_DELAY_MS = 25;
|
|
23
|
+
/**
|
|
24
|
+
* Fixed per-statement wire overhead reserved before sizing the request payload:
|
|
25
|
+
* topic (32) + channel (32) + expiry (8) + proof signature (64) + signer (32).
|
|
26
|
+
* Mirrors the Android/iOS sessions, which size message batches against
|
|
27
|
+
* `maxStatementSize - overhead` rather than the raw statement limit.
|
|
28
|
+
*/
|
|
29
|
+
export const STATEMENT_OVERHEAD = 32 + 32 + 8 + 64 + 32; // 168 bytes
|
|
30
|
+
// Encode/decode a StatementData envelope, surfacing scale-ts throws as a Result.
|
|
31
|
+
const encodeStatementData = fromThrowable(StatementData.enc, toError);
|
|
32
|
+
const decodeStatementData = fromThrowable(StatementData.dec, toError);
|
|
33
|
+
// Best-effort recovery of the requestId from a decrypted-but-undecodable payload. The requestId
|
|
34
|
+
// is the first field after the enum tag, so it usually survives a corrupt message body. Only
|
|
35
|
+
// requests (tag 0) carry an id we should answer; responses (tag 1) and unrecoverable payloads
|
|
36
|
+
// return null and are dropped rather than NACKed.
|
|
37
|
+
const RequestIdPrefix = Struct({ requestId: str });
|
|
38
|
+
const decodeRequestIdPrefix = fromThrowable(
|
|
39
|
+
// slice (a copy), not subarray: scale-ts decodes from the backing buffer start and ignores
|
|
40
|
+
// a view's byteOffset, so a subarray would be read from the wrong position.
|
|
41
|
+
(decrypted) => RequestIdPrefix.dec(decrypted.slice(1)).requestId, () => null);
|
|
42
|
+
function recoverRequestId(decrypted) {
|
|
43
|
+
if (decrypted.length < 1 || decrypted[0] !== 0)
|
|
44
|
+
return null;
|
|
45
|
+
return decodeRequestIdPrefix(decrypted).unwrapOr(null);
|
|
46
|
+
}
|
|
47
|
+
// nanoid() is fixed-length, so the requestId contributes a constant size; any
|
|
48
|
+
// placeholder of that length yields the real encoded size.
|
|
49
|
+
const SIZING_REQUEST_ID = 'x'.repeat(21);
|
|
50
|
+
// Encoded size of the request payload these messages would occupy in a statement's `data`
|
|
51
|
+
// field — the full SCALE envelope (requestId + vector framing), not just the raw bytes. This
|
|
52
|
+
// is what must fit the per-statement budget, matching iOS/Android (which size the full payload).
|
|
53
|
+
function requestPayloadSize(messages) {
|
|
54
|
+
return encodeStatementData({ tag: 'request', value: { requestId: SIZING_REQUEST_ID, data: messages } })
|
|
55
|
+
.map(d => d.length)
|
|
56
|
+
.unwrapOr(Number.MAX_SAFE_INTEGER); // unencodable → treat as "doesn't fit"
|
|
57
|
+
}
|
|
58
|
+
// A response promise paired with its resolver/rejecter. The pre-attached catch
|
|
59
|
+
// keeps a clearOutgoingStatement()/dispose() rejection from surfacing as an
|
|
60
|
+
// unhandled rejection when no caller awaited it via waitForResponseMessage().
|
|
61
|
+
function makeDeferred() {
|
|
62
|
+
let resolve;
|
|
63
|
+
let reject;
|
|
64
|
+
const promise = new Promise((res, rej) => {
|
|
65
|
+
resolve = res;
|
|
66
|
+
reject = rej;
|
|
67
|
+
});
|
|
68
|
+
promise.catch(() => undefined);
|
|
69
|
+
return { resolve, reject, promise };
|
|
70
|
+
}
|
|
71
|
+
// Retry a submit on failure with a short backoff. `shouldRetry` is re-checked before each retry:
|
|
72
|
+
// once the submission is superseded, aborted, or the session is disposed it returns false, so a
|
|
73
|
+
// stale retry can never resurrect an old statement.
|
|
74
|
+
//
|
|
75
|
+
// ExpiryTooLow is treated specially: it is never a chain/statement failure, only a sign our
|
|
76
|
+
// in-memory expiry lagged the channel's on-chain priority. submitStatementData has already resynced
|
|
77
|
+
// us above the reported minimum, so the next attempt submits higher. We therefore retry ExpiryTooLow
|
|
78
|
+
// WITHOUT spending the `attemptsLeft` transient-failure budget — keeping at it until it lands or the
|
|
79
|
+
// submission is superseded — and, once superseded, swallow it as success. The upshot: ExpiryTooLow
|
|
80
|
+
// never surfaces to callers. (Other errors keep the bounded retry and propagate when exhausted.)
|
|
81
|
+
function submitWithRetry(submit, attemptsLeft, shouldRetry) {
|
|
82
|
+
// How to settle once we stop retrying: a no-longer-live submission rejected with ExpiryTooLow
|
|
83
|
+
// simply lost the channel race to a newer, higher-priority statement — benign, so report success.
|
|
84
|
+
const settle = (error) => !shouldRetry() && error instanceof ExpiryTooLowError ? okAsync(undefined) : errAsync(error);
|
|
85
|
+
return submit().orElse(error => {
|
|
86
|
+
const expiryTooLow = error instanceof ExpiryTooLowError;
|
|
87
|
+
if (!shouldRetry() || (!expiryTooLow && attemptsLeft <= 0))
|
|
88
|
+
return settle(error);
|
|
89
|
+
// ExpiryTooLow is always recoverable while live, so it doesn't consume the retry budget.
|
|
90
|
+
const nextAttempts = expiryTooLow ? attemptsLeft : attemptsLeft - 1;
|
|
91
|
+
return ResultAsync.fromSafePromise(new Promise(resolve => setTimeout(resolve, RETRY_DELAY_MS))).andThen(() => shouldRetry() ? submitWithRetry(submit, nextAttempts, shouldRetry) : settle(error));
|
|
92
|
+
});
|
|
16
93
|
}
|
|
17
94
|
export function createSession({ localAccount, remoteAccount, statementStore, encryption, prover, sessionKey, maxRequestSize = DEFAULT_MAX_REQUEST_SIZE, }) {
|
|
18
95
|
const outgoingSessionId = createSessionId(sessionKey, localAccount, remoteAccount);
|
|
19
96
|
const incomingSessionId = createSessionId(sessionKey, remoteAccount, localAccount);
|
|
97
|
+
// Message bytes must fit within the statement limit minus the fixed wire overhead.
|
|
98
|
+
const maxPayloadSize = Math.max(0, maxRequestSize - STATEMENT_OVERHEAD);
|
|
20
99
|
const state = {
|
|
21
100
|
phase: 'initialization',
|
|
22
101
|
initError: null,
|
|
23
102
|
expiry: 0n,
|
|
24
103
|
outgoingRequest: null,
|
|
25
|
-
|
|
26
|
-
respondedIncomingRequest: false,
|
|
104
|
+
incomingRequests: new Map(),
|
|
27
105
|
messageQueue: [],
|
|
28
106
|
pendingDelivery: new Map(),
|
|
29
107
|
seenStatements: new Set(),
|
|
30
108
|
};
|
|
31
109
|
let subscribers = [];
|
|
110
|
+
// Reject callbacks for in-flight waitForRequestMessage() promises, so dispose()
|
|
111
|
+
// can settle them instead of leaving them to hang forever.
|
|
112
|
+
const requestWaiters = new Set();
|
|
32
113
|
const bufferedMessages = [];
|
|
33
114
|
let storeUnsub = null;
|
|
34
|
-
let
|
|
35
|
-
|
|
115
|
+
let initRetries = 0;
|
|
116
|
+
let initRetryTimer = null;
|
|
117
|
+
let disposed = false;
|
|
118
|
+
// Id of the most recent response we initiated (responses share one channel, so only the
|
|
119
|
+
// latest is live — a retry for an older one must not resurrect it).
|
|
120
|
+
let lastResponseRequestId = null;
|
|
121
|
+
// Submit on `channel`/`topicSessionId` at the next (strictly increasing) expiry.
|
|
122
|
+
function submitStatementData(channel, topicSessionId, data) {
|
|
123
|
+
state.expiry = nextExpiry(state.expiry);
|
|
124
|
+
const expiry = state.expiry;
|
|
36
125
|
return encryption
|
|
37
126
|
.encrypt(data)
|
|
38
127
|
.map(encrypted => ({
|
|
@@ -42,18 +131,46 @@ export function createSession({ localAccount, remoteAccount, statementStore, enc
|
|
|
42
131
|
data: encrypted,
|
|
43
132
|
}))
|
|
44
133
|
.asyncAndThen(prover.generateMessageProof)
|
|
45
|
-
.andThen(statementStore.submitStatement)
|
|
134
|
+
.andThen(statementStore.submitStatement)
|
|
135
|
+
.orElse(error => {
|
|
136
|
+
// The chain is the source of truth for a channel's priority. If our in-memory counter
|
|
137
|
+
// drifted behind it (a prior run, another writer, or propagation lag), resync to the
|
|
138
|
+
// reported minimum so the retry — and every later submit — clears it.
|
|
139
|
+
if (error instanceof ExpiryTooLowError && error.min > state.expiry)
|
|
140
|
+
state.expiry = error.min;
|
|
141
|
+
return errAsync(error);
|
|
142
|
+
});
|
|
46
143
|
}
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
144
|
+
// Settle and remove the pending-delivery entries for the given tokens.
|
|
145
|
+
function settleTokens(tokens, settle) {
|
|
146
|
+
for (const token of tokens) {
|
|
147
|
+
const deferred = state.pendingDelivery.get(token);
|
|
148
|
+
if (deferred) {
|
|
149
|
+
settle(deferred);
|
|
150
|
+
state.pendingDelivery.delete(token);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
50
153
|
}
|
|
51
154
|
function encodeAndSubmitRequest(requestId, messages) {
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
155
|
+
encodeStatementData({ tag: 'request', value: { requestId, data: messages } })
|
|
156
|
+
.asyncAndThen(data => submitWithRetry(() => submitStatementData(createRequestChannel(outgoingSessionId), outgoingSessionId, data), MAX_SUBMIT_RETRIES,
|
|
157
|
+
// Only keep retrying while this is still the live submission (not superseded by a
|
|
158
|
+
// newer retransmit, aborted via clearOutgoingStatement, or disposed).
|
|
159
|
+
() => !disposed && state.outgoingRequest?.requestIds.at(-1) === requestId))
|
|
55
160
|
.mapErr(e => {
|
|
161
|
+
// ExpiryTooLow is handled in submitWithRetry (retried until it lands, swallowed once
|
|
162
|
+
// superseded), so an error reaching here is a different, genuine failure. If this submission
|
|
163
|
+
// was already superseded by a newer retransmit (same tokens) it is not the live request's
|
|
164
|
+
// concern — drop it silently; the newer one carries the waiters. Otherwise the bounded
|
|
165
|
+
// retries are exhausted on the LIVE submission: the request never landed, so fail its
|
|
166
|
+
// waiters rather than let them hang.
|
|
167
|
+
const outgoing = state.outgoingRequest;
|
|
168
|
+
if (disposed || !outgoing || outgoing.requestIds.at(-1) !== requestId)
|
|
169
|
+
return;
|
|
56
170
|
console.error('submitRequest failed:', e);
|
|
171
|
+
settleTokens(outgoing.tokens, deferred => deferred.reject(e));
|
|
172
|
+
state.outgoingRequest = null;
|
|
173
|
+
processMessageQueue();
|
|
57
174
|
});
|
|
58
175
|
}
|
|
59
176
|
function deliverStatementData(statementData) {
|
|
@@ -73,36 +190,58 @@ export function createSession({ localAccount, remoteAccount, statementStore, enc
|
|
|
73
190
|
}
|
|
74
191
|
function tryDecodeStatement(statement) {
|
|
75
192
|
if (!statement.data)
|
|
76
|
-
return okAsync(null);
|
|
193
|
+
return okAsync({ kind: 'undecodable', requestId: null });
|
|
77
194
|
const data = statement.data;
|
|
78
|
-
return prover
|
|
195
|
+
return (prover
|
|
79
196
|
.verifyMessageProof(statement)
|
|
80
197
|
.andThen(verified => (verified ? ok() : err(new Error('Invalid proof'))))
|
|
81
198
|
.andThen(() => encryption.decrypt(data))
|
|
82
|
-
.map(decrypted =>
|
|
83
|
-
|
|
199
|
+
.map(decrypted => {
|
|
200
|
+
const decoded = decodeStatementData(decrypted);
|
|
201
|
+
return decoded.isOk()
|
|
202
|
+
? { kind: 'decoded', data: decoded.value }
|
|
203
|
+
: { kind: 'undecodable', requestId: recoverRequestId(decrypted) };
|
|
204
|
+
})
|
|
205
|
+
// Proof or decryption failure: the payload (incl. the requestId) is unreadable → drop.
|
|
206
|
+
.orElse(() => okAsync({ kind: 'undecodable', requestId: null })));
|
|
84
207
|
}
|
|
85
|
-
function processIncomingStatement(statement
|
|
208
|
+
function processIncomingStatement(statement) {
|
|
86
209
|
if (!statement.data)
|
|
87
210
|
return;
|
|
88
211
|
const key = toHex(statement.data);
|
|
89
212
|
if (state.seenStatements.has(key))
|
|
90
213
|
return;
|
|
91
214
|
state.seenStatements.add(key);
|
|
92
|
-
tryDecodeStatement(statement).andTee(
|
|
93
|
-
if (
|
|
215
|
+
void tryDecodeStatement(statement).andTee(outcome => {
|
|
216
|
+
if (outcome.kind === 'undecodable') {
|
|
217
|
+
if (outcome.requestId === null) {
|
|
218
|
+
// Proof/decryption failed, or no requestId was recoverable — nothing to NACK.
|
|
219
|
+
console.warn('statement-store: dropping an undecodable incoming statement (no recoverable requestId)');
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
// Only NACK a genuinely new id. If we already know this request, a valid copy is being
|
|
223
|
+
// handled (or was already answered) — NACKing now would mask the real response, since the
|
|
224
|
+
// `responded` flag is sticky.
|
|
225
|
+
if (state.incomingRequests.has(outcome.requestId))
|
|
226
|
+
return;
|
|
227
|
+
// Decrypted but the message body is malformed — NACK so the sender stops waiting.
|
|
228
|
+
state.incomingRequests.set(outcome.requestId, { responded: false });
|
|
229
|
+
void session
|
|
230
|
+
.submitResponseMessage(outcome.requestId, 'decodingFailed')
|
|
231
|
+
.mapErr(e => console.error('statement-store: failed to NACK an undecodable request:', e));
|
|
94
232
|
return;
|
|
233
|
+
}
|
|
234
|
+
const statementData = outcome.data;
|
|
95
235
|
if (statementData.tag === 'request') {
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
if (statementData.value.requestId === state.incomingRequest?.requestId)
|
|
236
|
+
const requestId = statementData.value.requestId;
|
|
237
|
+
if (state.incomingRequests.has(requestId))
|
|
99
238
|
return;
|
|
100
|
-
state.
|
|
101
|
-
state.respondedIncomingRequest = false;
|
|
239
|
+
state.incomingRequests.set(requestId, { responded: false });
|
|
102
240
|
deliverStatementData(statementData);
|
|
103
241
|
}
|
|
104
242
|
else if (statementData.tag === 'response') {
|
|
105
|
-
|
|
243
|
+
const outgoing = state.outgoingRequest;
|
|
244
|
+
if (!outgoing?.requestIds.includes(statementData.value.requestId))
|
|
106
245
|
return;
|
|
107
246
|
const responseMessage = {
|
|
108
247
|
type: 'response',
|
|
@@ -110,66 +249,82 @@ export function createSession({ localAccount, remoteAccount, statementStore, enc
|
|
|
110
249
|
requestId: statementData.value.requestId,
|
|
111
250
|
responseCode: statementData.value.responseCode,
|
|
112
251
|
};
|
|
113
|
-
|
|
114
|
-
const deferred = state.pendingDelivery.get(token);
|
|
115
|
-
if (deferred) {
|
|
116
|
-
deferred.resolve(responseMessage);
|
|
117
|
-
state.pendingDelivery.delete(token);
|
|
118
|
-
}
|
|
119
|
-
}
|
|
252
|
+
settleTokens(outgoing.tokens, deferred => deferred.resolve(responseMessage));
|
|
120
253
|
state.outgoingRequest = null;
|
|
121
254
|
deliverStatementData(statementData);
|
|
122
255
|
processMessageQueue();
|
|
123
256
|
}
|
|
124
257
|
});
|
|
125
258
|
}
|
|
126
|
-
|
|
259
|
+
// Returns true if `encoded` matches a message already in flight or queued, after
|
|
260
|
+
// attaching `token` to it so the caller resolves on that message's response
|
|
261
|
+
// instead of the bytes being submitted a second time.
|
|
262
|
+
function attachToDuplicate(encoded, token) {
|
|
263
|
+
const encodedHex = toHex(encoded);
|
|
264
|
+
const sameBytes = (m) => m.length === encoded.length && toHex(m) === encodedHex;
|
|
265
|
+
const outgoing = state.outgoingRequest;
|
|
266
|
+
if (outgoing && outgoing.messages.some(sameBytes)) {
|
|
267
|
+
outgoing.tokens.push(token);
|
|
268
|
+
return true;
|
|
269
|
+
}
|
|
270
|
+
const queued = state.messageQueue.find(entry => sameBytes(entry.encoded));
|
|
271
|
+
if (queued) {
|
|
272
|
+
queued.tokens.push(token);
|
|
273
|
+
return true;
|
|
274
|
+
}
|
|
275
|
+
return false;
|
|
276
|
+
}
|
|
277
|
+
function processNewMessage(encoded, tokens) {
|
|
127
278
|
if (state.outgoingRequest === null) {
|
|
128
279
|
const requestId = nanoid();
|
|
129
|
-
state.outgoingRequest = { requestIds: [requestId], messages: [encoded], tokens: [
|
|
280
|
+
state.outgoingRequest = { requestIds: [requestId], messages: [encoded], tokens: [...tokens] };
|
|
130
281
|
encodeAndSubmitRequest(requestId, state.outgoingRequest.messages);
|
|
131
282
|
}
|
|
283
|
+
else if (requestPayloadSize([...state.outgoingRequest.messages, encoded]) <= maxPayloadSize) {
|
|
284
|
+
state.outgoingRequest.messages.push(encoded);
|
|
285
|
+
state.outgoingRequest.tokens.push(...tokens);
|
|
286
|
+
const newRequestId = nanoid();
|
|
287
|
+
state.outgoingRequest.requestIds.push(newRequestId);
|
|
288
|
+
encodeAndSubmitRequest(newRequestId, state.outgoingRequest.messages);
|
|
289
|
+
}
|
|
132
290
|
else {
|
|
133
|
-
|
|
134
|
-
if (currentTotal + encoded.length <= maxRequestSize) {
|
|
135
|
-
state.outgoingRequest.messages.push(encoded);
|
|
136
|
-
state.outgoingRequest.tokens.push(token);
|
|
137
|
-
const newRequestId = nanoid();
|
|
138
|
-
state.outgoingRequest.requestIds.push(newRequestId);
|
|
139
|
-
encodeAndSubmitRequest(newRequestId, state.outgoingRequest.messages);
|
|
140
|
-
}
|
|
141
|
-
else {
|
|
142
|
-
state.messageQueue.push({ encoded, token });
|
|
143
|
-
}
|
|
291
|
+
state.messageQueue.push({ encoded, tokens });
|
|
144
292
|
}
|
|
145
293
|
}
|
|
146
294
|
function processMessageQueue() {
|
|
147
295
|
while (state.messageQueue.length > 0) {
|
|
148
296
|
const head = state.messageQueue[0];
|
|
149
297
|
// Recompute per iteration; `processNewMessage` mutates outgoingRequest.messages in place.
|
|
150
|
-
|
|
151
|
-
|
|
298
|
+
if (state.outgoingRequest !== null &&
|
|
299
|
+
requestPayloadSize([...state.outgoingRequest.messages, head.encoded]) > maxPayloadSize) {
|
|
152
300
|
break;
|
|
301
|
+
}
|
|
153
302
|
state.messageQueue.shift();
|
|
154
|
-
processNewMessage(head.encoded, head.
|
|
303
|
+
processNewMessage(head.encoded, head.tokens);
|
|
155
304
|
}
|
|
156
305
|
}
|
|
157
306
|
function ensureStoreSubscription() {
|
|
158
307
|
if (storeUnsub)
|
|
159
308
|
return;
|
|
309
|
+
// A single subscription on the incoming topic carries BOTH the peer's requests
|
|
310
|
+
// and the peer's responses to our requests (the peer publishes everything on its
|
|
311
|
+
// outgoing topic = our incoming topic). We publish on the outgoing topic, which
|
|
312
|
+
// we don't subscribe to, so our own statements are never echoed back.
|
|
160
313
|
storeUnsub = statementStore.subscribeStatements({ matchAll: [incomingSessionId] }, page => {
|
|
161
314
|
for (const statement of page.statements) {
|
|
162
315
|
processIncomingStatement(statement);
|
|
163
316
|
}
|
|
164
317
|
});
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
318
|
+
}
|
|
319
|
+
// Once a request is answered it no longer needs to be replayed to future subscribers (and a
|
|
320
|
+
// late waitForRequestMessage must not re-receive an already-handled request). Dropping it also
|
|
321
|
+
// keeps bufferedMessages from growing unboundedly with every incoming request.
|
|
322
|
+
function pruneBufferedRequest(requestId) {
|
|
323
|
+
for (let i = bufferedMessages.length - 1; i >= 0; i--) {
|
|
324
|
+
const sd = bufferedMessages[i];
|
|
325
|
+
if (sd && sd.tag === 'request' && sd.value.requestId === requestId)
|
|
326
|
+
bufferedMessages.splice(i, 1);
|
|
327
|
+
}
|
|
173
328
|
}
|
|
174
329
|
function rejectAllPending(error) {
|
|
175
330
|
for (const [, deferred] of state.pendingDelivery) {
|
|
@@ -184,38 +339,61 @@ export function createSession({ localAccount, remoteAccount, statementStore, enc
|
|
|
184
339
|
rejectAllPending(error);
|
|
185
340
|
}
|
|
186
341
|
async function init() {
|
|
187
|
-
const
|
|
342
|
+
const result = await ResultAsync.combine([
|
|
188
343
|
statementStore.queryStatements({ matchAll: [outgoingSessionId] }),
|
|
189
344
|
statementStore.queryStatements({ matchAll: [incomingSessionId] }),
|
|
190
345
|
]);
|
|
191
|
-
if (
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
346
|
+
if (result.isErr()) {
|
|
347
|
+
if (disposed)
|
|
348
|
+
return;
|
|
349
|
+
// Transient transport failure: retry init (preserving the message queue) before
|
|
350
|
+
// giving up. Only after the cap is reached do we fail terminally.
|
|
351
|
+
if (initRetries < MAX_INIT_RETRIES) {
|
|
352
|
+
initRetries++;
|
|
353
|
+
// Store the handle so dispose() can cancel it — otherwise a disposed session keeps
|
|
354
|
+
// querying and can re-activate itself if a late retry succeeds.
|
|
355
|
+
initRetryTimer = setTimeout(() => {
|
|
356
|
+
initRetryTimer = null;
|
|
357
|
+
void init();
|
|
358
|
+
}, RETRY_DELAY_MS);
|
|
359
|
+
return;
|
|
360
|
+
}
|
|
361
|
+
failInit(result.error);
|
|
197
362
|
return;
|
|
198
363
|
}
|
|
199
|
-
|
|
200
|
-
const peerStatements =
|
|
364
|
+
initRetries = 0;
|
|
365
|
+
const [ownStatements, peerStatements] = result.value;
|
|
201
366
|
let maxExpiry = 0n;
|
|
202
367
|
for (const s of ownStatements) {
|
|
203
368
|
if (s.expiry !== undefined && s.expiry > maxExpiry)
|
|
204
369
|
maxExpiry = s.expiry;
|
|
205
370
|
}
|
|
206
|
-
|
|
371
|
+
// Never regress the counter. The query is a snapshot taken when init() began; a statement
|
|
372
|
+
// submitted while init was in flight (e.g. an auto-ACK for a peer request that arrived during
|
|
373
|
+
// the query) has already advanced both state.expiry and the on-chain channel past that
|
|
374
|
+
// snapshot. Overwriting unconditionally would drop the counter below the on-chain priority,
|
|
375
|
+
// making the next submit collide at an equal expiry.
|
|
376
|
+
const seeded = nextExpiry(maxExpiry);
|
|
377
|
+
if (seeded > state.expiry)
|
|
378
|
+
state.expiry = seeded;
|
|
207
379
|
for (const s of [...ownStatements, ...peerStatements]) {
|
|
208
380
|
if (s.data)
|
|
209
381
|
state.seenStatements.add(toHex(s.data));
|
|
210
382
|
}
|
|
211
|
-
const decodeAll = (statements) => Promise.all(statements.map(s => tryDecodeStatement(s).
|
|
383
|
+
const decodeAll = (statements) => Promise.all(statements.map(s => tryDecodeStatement(s).unwrapOr({ kind: 'undecodable', requestId: null }))).then(outcomes => outcomes.map(o => (o.kind === 'decoded' ? o.data : null)).filter(nonNullable));
|
|
212
384
|
const [ownDecoded, peerDecoded] = await Promise.all([decodeAll(ownStatements), decodeAll(peerStatements)]);
|
|
385
|
+
if (disposed)
|
|
386
|
+
return;
|
|
387
|
+
// Both parties publish on their own outgoing topic, so the OUTGOING query returns our
|
|
388
|
+
// requests + OUR responses, and the INCOMING query returns the peer's requests + the
|
|
389
|
+
// PEER's responses. Hence: our request is answered by a PEER response (incoming), and we
|
|
390
|
+
// have answered a peer request iff OUR response (outgoing) carries its id.
|
|
213
391
|
const ownRequest = ownDecoded.find(d => d.tag === 'request');
|
|
214
392
|
const ownResponse = ownDecoded.find(d => d.tag === 'response');
|
|
215
393
|
const peerRequest = peerDecoded.find(d => d.tag === 'request');
|
|
216
394
|
const peerResponse = peerDecoded.find(d => d.tag === 'response');
|
|
217
395
|
if (ownRequest?.tag === 'request') {
|
|
218
|
-
const hasResponse =
|
|
396
|
+
const hasResponse = peerResponse?.tag === 'response' && peerResponse.value.requestId === ownRequest.value.requestId;
|
|
219
397
|
if (!hasResponse) {
|
|
220
398
|
state.outgoingRequest = {
|
|
221
399
|
requestIds: [ownRequest.value.requestId],
|
|
@@ -225,15 +403,18 @@ export function createSession({ localAccount, remoteAccount, statementStore, enc
|
|
|
225
403
|
}
|
|
226
404
|
}
|
|
227
405
|
if (peerRequest?.tag === 'request') {
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
406
|
+
const requestId = peerRequest.value.requestId;
|
|
407
|
+
// Don't clobber an entry a live delivery may have created during the awaits
|
|
408
|
+
// above (the live one is newer/authoritative).
|
|
409
|
+
if (!state.incomingRequests.has(requestId)) {
|
|
410
|
+
const responded = ownResponse?.tag === 'response' && ownResponse.value.requestId === requestId;
|
|
411
|
+
state.incomingRequests.set(requestId, { responded });
|
|
412
|
+
// Notify app of an unresponded incoming request. Delivered while phase is
|
|
413
|
+
// still 'initialization' so deliverStatementData buffers it for replay if
|
|
414
|
+
// no subscriber is registered yet.
|
|
415
|
+
if (!responded)
|
|
416
|
+
deliverStatementData(peerRequest);
|
|
417
|
+
}
|
|
237
418
|
}
|
|
238
419
|
state.phase = 'active';
|
|
239
420
|
processMessageQueue();
|
|
@@ -245,66 +426,130 @@ export function createSession({ localAccount, remoteAccount, statementStore, enc
|
|
|
245
426
|
.andThen(({ requestId }) => session.waitForResponseMessage(requestId).andThen(({ responseCode }) => mapResponseCode(responseCode)));
|
|
246
427
|
},
|
|
247
428
|
submitRequestMessage(codec, message) {
|
|
429
|
+
if (disposed)
|
|
430
|
+
return errAsync(new Error(SESSION_DISPOSED));
|
|
248
431
|
const encode = fromThrowable(codec.enc, toError);
|
|
249
432
|
const encodedResult = encode(message);
|
|
250
433
|
if (encodedResult.isErr())
|
|
251
434
|
return errAsync(encodedResult.error);
|
|
252
435
|
const encoded = encodedResult.value;
|
|
253
|
-
if (encoded
|
|
436
|
+
if (requestPayloadSize([encoded]) > maxPayloadSize)
|
|
254
437
|
return errAsync(new Error('message too big'));
|
|
255
438
|
if (state.phase === 'failed') {
|
|
256
439
|
return errAsync(state.initError ?? new Error('Session initialization failed'));
|
|
257
440
|
}
|
|
258
441
|
const token = nanoid();
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
state.messageQueue.push({ encoded, token });
|
|
272
|
-
}
|
|
273
|
-
else {
|
|
274
|
-
processNewMessage(encoded, token);
|
|
442
|
+
state.pendingDelivery.set(token, makeDeferred());
|
|
443
|
+
// Dedup: an identical message already in flight or queued is not re-sent — the
|
|
444
|
+
// new caller is attached to it and resolves on the same response.
|
|
445
|
+
if (!attachToDuplicate(encoded, token)) {
|
|
446
|
+
// FIFO: never let a later (fitting) message overtake queued ones; only append
|
|
447
|
+
// to the live batch when nothing is waiting behind it.
|
|
448
|
+
if (state.phase === 'initialization' || state.messageQueue.length > 0) {
|
|
449
|
+
state.messageQueue.push({ encoded, tokens: [token] });
|
|
450
|
+
}
|
|
451
|
+
else {
|
|
452
|
+
processNewMessage(encoded, [token]);
|
|
453
|
+
}
|
|
275
454
|
}
|
|
276
455
|
return okAsync({ requestId: token });
|
|
277
456
|
},
|
|
278
457
|
submitResponseMessage(requestId, responseCode) {
|
|
279
|
-
if (
|
|
280
|
-
return
|
|
281
|
-
|
|
458
|
+
if (disposed)
|
|
459
|
+
return errAsync(new Error(SESSION_DISPOSED));
|
|
460
|
+
const incoming = state.incomingRequests.get(requestId);
|
|
461
|
+
if (!incoming)
|
|
282
462
|
return errAsync(new Error(`No incoming request with id ${requestId}`));
|
|
463
|
+
if (incoming.responded) {
|
|
464
|
+
pruneBufferedRequest(requestId);
|
|
465
|
+
return okAsync(undefined);
|
|
283
466
|
}
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
467
|
+
const encoded = encodeStatementData({ tag: 'response', value: { requestId, responseCode } });
|
|
468
|
+
if (encoded.isErr())
|
|
469
|
+
return errAsync(encoded.error);
|
|
470
|
+
// Mark responded up-front so concurrent callers dedupe, but roll back if the
|
|
471
|
+
// submission fails — otherwise the ACK is lost forever (and a peer retransmit
|
|
472
|
+
// with a fresh id could never be answered either).
|
|
473
|
+
incoming.responded = true;
|
|
474
|
+
lastResponseRequestId = requestId;
|
|
475
|
+
// Responses go on OUR outgoing topic/response-channel (per spec: the responder
|
|
476
|
+
// publishes on SessionId(self, peer)); the requester reads them from its incoming topic.
|
|
477
|
+
return (submitWithRetry(() => submitStatementData(createResponseChannel(outgoingSessionId), outgoingSessionId, encoded.value), MAX_SUBMIT_RETRIES,
|
|
478
|
+
// Stop retrying once a newer response supersedes this one (shared response channel) or disposed.
|
|
479
|
+
() => !disposed && lastResponseRequestId === requestId)
|
|
480
|
+
// Answered: it no longer needs replaying to future subscribers.
|
|
481
|
+
.andTee(() => pruneBufferedRequest(requestId))
|
|
482
|
+
.orElse(error => {
|
|
483
|
+
// ExpiryTooLow is handled in submitWithRetry (swallowed once a newer response supersedes
|
|
484
|
+
// this one on the shared channel), so an error here is a different failure. If this is no
|
|
485
|
+
// longer the latest response (superseded) or the session is disposed, keep the request
|
|
486
|
+
// marked answered — re-answering would only clobber the newer response — and absorb the
|
|
487
|
+
// error. NOTE: the shared response channel still only exposes the latest response to the
|
|
488
|
+
// peer, so reliably ACKing several outstanding requests needs the protocol-level fix
|
|
489
|
+
// tracked separately.
|
|
490
|
+
if (disposed || lastResponseRequestId !== requestId) {
|
|
491
|
+
pruneBufferedRequest(requestId);
|
|
492
|
+
return okAsync(undefined);
|
|
493
|
+
}
|
|
494
|
+
// The live response genuinely failed after exhausting retries — roll back so a later
|
|
495
|
+
// peer retransmit can still be answered, and surface the error.
|
|
496
|
+
incoming.responded = false;
|
|
497
|
+
return errAsync(error);
|
|
498
|
+
}));
|
|
287
499
|
},
|
|
288
500
|
waitForRequestMessage(codec, filter) {
|
|
289
|
-
const promise = new Promise(resolve => {
|
|
290
|
-
|
|
501
|
+
const promise = new Promise((resolve, reject) => {
|
|
502
|
+
let settled = false;
|
|
503
|
+
// Initialised to a no-op so a synchronous buffered-replay match during
|
|
504
|
+
// subscribe() can call it without hitting the temporal dead zone; the
|
|
505
|
+
// real unsubscribe is wired in once subscribe() returns.
|
|
506
|
+
let unsubscribe = () => undefined;
|
|
507
|
+
const finish = (run) => {
|
|
508
|
+
if (settled)
|
|
509
|
+
return;
|
|
510
|
+
settled = true;
|
|
511
|
+
requestWaiters.delete(rejectWaiter);
|
|
512
|
+
unsubscribe();
|
|
513
|
+
run();
|
|
514
|
+
};
|
|
515
|
+
const rejectWaiter = (error) => finish(() => reject(error));
|
|
516
|
+
requestWaiters.add(rejectWaiter);
|
|
517
|
+
unsubscribe = session.subscribe(codec, messages => {
|
|
291
518
|
for (const message of messages) {
|
|
292
519
|
if (message.type !== 'request')
|
|
293
520
|
continue;
|
|
294
|
-
|
|
295
|
-
if (payload.status !== 'parsed')
|
|
521
|
+
if (message.payload.status !== 'parsed')
|
|
296
522
|
continue;
|
|
297
|
-
const filtered = filter(payload.value);
|
|
298
|
-
if (filtered) {
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
break;
|
|
523
|
+
const filtered = filter(message.payload.value);
|
|
524
|
+
if (filtered !== undefined) {
|
|
525
|
+
finish(() => resolve(filtered));
|
|
526
|
+
return;
|
|
302
527
|
}
|
|
303
528
|
}
|
|
304
529
|
});
|
|
530
|
+
// subscribe() may have matched synchronously (buffered replay) while
|
|
531
|
+
// `unsubscribe` was still the no-op above — tear down the real one now.
|
|
532
|
+
if (settled)
|
|
533
|
+
unsubscribe();
|
|
305
534
|
});
|
|
306
535
|
return fromPromise(promise, toError);
|
|
307
536
|
},
|
|
537
|
+
respondToRequests(codec, handler) {
|
|
538
|
+
return session.subscribe(codec, messages => {
|
|
539
|
+
for (const message of messages) {
|
|
540
|
+
if (message.type !== 'request')
|
|
541
|
+
continue;
|
|
542
|
+
const handled = handler(message);
|
|
543
|
+
const statusResult = handled instanceof ResultAsync ? handled : okAsync(handled);
|
|
544
|
+
void statusResult
|
|
545
|
+
.orElse(() => okAsync('unknown'))
|
|
546
|
+
.andThen(code => session.submitResponseMessage(message.requestId, code))
|
|
547
|
+
.mapErr(e => {
|
|
548
|
+
console.error('respondToRequests: failed to submit response:', e);
|
|
549
|
+
});
|
|
550
|
+
}
|
|
551
|
+
});
|
|
552
|
+
},
|
|
308
553
|
waitForResponseMessage(token) {
|
|
309
554
|
const deferred = state.pendingDelivery.get(token);
|
|
310
555
|
if (!deferred)
|
|
@@ -326,24 +571,14 @@ export function createSession({ localAccount, remoteAccount, statementStore, enc
|
|
|
326
571
|
}
|
|
327
572
|
return () => {
|
|
328
573
|
subscribers = subscribers.filter(s => s !== sub);
|
|
329
|
-
if (subscribers.length === 0) {
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
storeUnsub = null;
|
|
333
|
-
}
|
|
334
|
-
if (responseStoreUnsub) {
|
|
335
|
-
responseStoreUnsub();
|
|
336
|
-
responseStoreUnsub = null;
|
|
337
|
-
}
|
|
574
|
+
if (subscribers.length === 0 && storeUnsub) {
|
|
575
|
+
storeUnsub();
|
|
576
|
+
storeUnsub = null;
|
|
338
577
|
}
|
|
339
578
|
};
|
|
340
579
|
},
|
|
341
580
|
clearOutgoingStatement() {
|
|
342
581
|
const outgoing = state.outgoingRequest;
|
|
343
|
-
// Reuse the current expiry (do NOT call nextExpiry): the live batch was last
|
|
344
|
-
// submitted at state.expiry, so an empty statement at the same expiry on the
|
|
345
|
-
// same channel supersedes it. The store rejects only a strictly lower expiry.
|
|
346
|
-
const expiry = state.expiry;
|
|
347
582
|
// Always drop local outgoing state and reject pending waiters up-front,
|
|
348
583
|
// regardless of which path follows. This covers messages queued before the
|
|
349
584
|
// batch went out (e.g. during init, while outgoingRequest is still null) and
|
|
@@ -355,21 +590,36 @@ export function createSession({ localAccount, remoteAccount, statementStore, enc
|
|
|
355
590
|
if (outgoing === null)
|
|
356
591
|
return okAsync(undefined);
|
|
357
592
|
const requestId = outgoing.requestIds[outgoing.requestIds.length - 1];
|
|
358
|
-
const encoded =
|
|
359
|
-
tag: 'request',
|
|
360
|
-
value: { requestId, data: [] },
|
|
361
|
-
});
|
|
593
|
+
const encoded = encodeStatementData({ tag: 'request', value: { requestId, data: [] } });
|
|
362
594
|
if (encoded.isErr())
|
|
363
595
|
return errAsync(encoded.error);
|
|
364
|
-
|
|
596
|
+
// Supersede the live batch with an empty one. Use submitStatementData so the
|
|
597
|
+
// empty statement goes out at a STRICTLY higher expiry — the store rejects an
|
|
598
|
+
// equal-or-lower expiry on the same channel, so reusing state.expiry would
|
|
599
|
+
// leave the original request live on-chain. Route through submitWithRetry with
|
|
600
|
+
// shouldRetry:()=>false so it inherits the single ExpiryTooLow policy — a rejection
|
|
601
|
+
// means the channel already advanced past us, so the clear already happened → absorb it —
|
|
602
|
+
// without retrying (clearing is a one-shot supersede, not a request that must land).
|
|
603
|
+
return submitWithRetry(() => submitStatementData(createRequestChannel(outgoingSessionId), outgoingSessionId, encoded.value), 0, () => false);
|
|
365
604
|
},
|
|
366
605
|
dispose() {
|
|
606
|
+
disposed = true;
|
|
607
|
+
if (initRetryTimer) {
|
|
608
|
+
clearTimeout(initRetryTimer);
|
|
609
|
+
initRetryTimer = null;
|
|
610
|
+
}
|
|
367
611
|
storeUnsub?.();
|
|
368
612
|
storeUnsub = null;
|
|
369
|
-
responseStoreUnsub?.();
|
|
370
|
-
responseStoreUnsub = null;
|
|
371
613
|
subscribers = [];
|
|
372
|
-
|
|
614
|
+
// Drop pending work so no in-flight retry or queue drain acts on a disposed session.
|
|
615
|
+
state.outgoingRequest = null;
|
|
616
|
+
state.messageQueue = [];
|
|
617
|
+
// Settle any waitForRequestMessage() promises so callers unwind instead of
|
|
618
|
+
// hanging forever. Snapshot first — rejecting mutates the set.
|
|
619
|
+
for (const rejectWaiter of [...requestWaiters])
|
|
620
|
+
rejectWaiter(new Error(SESSION_DISPOSED));
|
|
621
|
+
requestWaiters.clear();
|
|
622
|
+
rejectAllPending(new Error(SESSION_DISPOSED));
|
|
373
623
|
},
|
|
374
624
|
};
|
|
375
625
|
void init();
|