@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
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
import { createExpiryFromDuration } from '@novasamatech/sdk-statement';
|
|
2
|
-
import { ResultAsync, errAsync, ok, okAsync } from 'neverthrow';
|
|
3
|
-
import { Bytes, str } from 'scale-ts';
|
|
2
|
+
import { ResultAsync, err, errAsync, ok, okAsync } from 'neverthrow';
|
|
3
|
+
import { Bytes, Struct, str } from 'scale-ts';
|
|
4
4
|
import { describe, expect, it, vi } from 'vitest';
|
|
5
|
+
import { createInMemoryStatementStore } from '../adapter/inMemory.js';
|
|
6
|
+
import { ExpiryTooLowError } from '../adapter/types.js';
|
|
5
7
|
import { createAccountId, createLocalSessionAccount, createRemoteSessionAccount } from '../model/sessionAccount.js';
|
|
8
|
+
import { DecodingError, UnknownError } from './error.js';
|
|
6
9
|
import { StatementData } from './scale/statementData.js';
|
|
7
|
-
import {
|
|
10
|
+
import { STATEMENT_OVERHEAD, createSession } from './session.js';
|
|
8
11
|
// Real signature work belongs in statementProver tests; this stub stamps a
|
|
9
12
|
// non-empty proof so submitted statements are well-formed.
|
|
10
13
|
const mockProver = {
|
|
@@ -47,10 +50,16 @@ function makeStatement(statementData, expiry) {
|
|
|
47
50
|
channel: `0x${'00'.repeat(32)}`,
|
|
48
51
|
};
|
|
49
52
|
}
|
|
50
|
-
function makeSession(
|
|
53
|
+
function makeSession(opts = {}) {
|
|
51
54
|
const { localAccount, remoteAccount } = makeAccounts();
|
|
52
|
-
const { maxRequestSize, ...adapterOverrides } =
|
|
53
|
-
const adapter =
|
|
55
|
+
const { own = [], peer = [], maxRequestSize, ...adapterOverrides } = opts;
|
|
56
|
+
const adapter = makeAdapter();
|
|
57
|
+
// init() queries the outgoing (own) topic first, then the incoming (peer) topic.
|
|
58
|
+
let queryCall = 0;
|
|
59
|
+
adapter.queryStatements.mockImplementation(() => okAsync(queryCall++ === 0 ? own : peer));
|
|
60
|
+
// Explicit adapter mocks (e.g. a capturing subscribeStatements, or a custom
|
|
61
|
+
// queryStatements) take precedence over the own/peer defaults.
|
|
62
|
+
Object.assign(adapter, adapterOverrides);
|
|
54
63
|
const session = createSession({
|
|
55
64
|
localAccount,
|
|
56
65
|
remoteAccount,
|
|
@@ -64,192 +73,371 @@ function makeSession(overrides) {
|
|
|
64
73
|
});
|
|
65
74
|
return { session, adapter };
|
|
66
75
|
}
|
|
76
|
+
// Capture the callbacks the session registers via subscribeStatements so a test can
|
|
77
|
+
// push statement pages itself. The session subscribes once, to the incoming topic
|
|
78
|
+
// (callbacks[0]), which carries both peer requests and peer responses.
|
|
79
|
+
function capturingSubscribe() {
|
|
80
|
+
const callbacks = [];
|
|
81
|
+
const subscribeStatements = vi.fn((_filter, cb) => {
|
|
82
|
+
callbacks.push(cb);
|
|
83
|
+
return vi.fn();
|
|
84
|
+
});
|
|
85
|
+
return { subscribeStatements, callbacks };
|
|
86
|
+
}
|
|
87
|
+
function lastSubmitted(adapter) {
|
|
88
|
+
return adapter.submitStatement.mock.calls.at(-1)[0];
|
|
89
|
+
}
|
|
90
|
+
function lastSubmittedRequestId(adapter) {
|
|
91
|
+
const decoded = StatementData.dec(lastSubmitted(adapter).data);
|
|
92
|
+
return decoded.tag === 'request' ? decoded.value.requestId : '';
|
|
93
|
+
}
|
|
94
|
+
// A submitStatement mock that defers every submission instead of resolving: each call records a
|
|
95
|
+
// `{ requestId, settle }` entry in `pendings`, letting a test land or reject submissions in a chosen
|
|
96
|
+
// order (used to drive shared-channel supersession races). Works for request and response payloads.
|
|
97
|
+
function deferredSubmit() {
|
|
98
|
+
const pendings = [];
|
|
99
|
+
const submitStatement = vi.fn((stmt) => {
|
|
100
|
+
const decoded = StatementData.dec(stmt.data);
|
|
101
|
+
const requestId = decoded.tag === 'request' || decoded.tag === 'response' ? decoded.value.requestId : '';
|
|
102
|
+
return ResultAsync.fromPromise(new Promise((resolve, reject) => {
|
|
103
|
+
pendings.push({ requestId, settle: r => (r.isOk() ? resolve() : reject(r.error)) });
|
|
104
|
+
}), e => e);
|
|
105
|
+
});
|
|
106
|
+
return { submitStatement, pendings };
|
|
107
|
+
}
|
|
108
|
+
// Encoded size of the request payload (the statement `data` field) for these messages —
|
|
109
|
+
// what the session sizes batches against. Includes the requestId (a fixed-length nanoid)
|
|
110
|
+
// and the SCALE vector framing, so it is larger than the raw message bytes alone.
|
|
111
|
+
function reqPayloadSize(...messages) {
|
|
112
|
+
return StatementData.enc({ tag: 'request', value: { requestId: 'x'.repeat(21), data: messages } }).length;
|
|
113
|
+
}
|
|
67
114
|
async function delay(ttl = 0) {
|
|
68
115
|
await new Promise(resolve => setTimeout(resolve, ttl));
|
|
69
116
|
}
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
117
|
+
// Drain microtasks + macrotasks repeatedly so a multi-hop cross-session
|
|
118
|
+
// exchange (submit → deliver → process → submit → …) fully settles.
|
|
119
|
+
async function settle() {
|
|
120
|
+
for (let i = 0; i < 12; i++)
|
|
121
|
+
await delay();
|
|
122
|
+
}
|
|
123
|
+
// Two mirrored sessions sharing one in-memory store. A SHARED session key makes
|
|
124
|
+
// them derive the same SessionId pair (host.outgoing === peer.incoming, and vice
|
|
125
|
+
// versa) — a real host/papp pairing. With identity encryption/prover, each side
|
|
126
|
+
// decrypts and verifies the other's statements.
|
|
127
|
+
const SHARED_KEY = new Uint8Array(32).fill(7);
|
|
128
|
+
const localA = createLocalSessionAccount(createAccountId(new Uint8Array(32).fill(1)));
|
|
129
|
+
const remoteA = createRemoteSessionAccount(createAccountId(new Uint8Array(32).fill(1)), new Uint8Array(32).fill(11));
|
|
130
|
+
const localB = createLocalSessionAccount(createAccountId(new Uint8Array(32).fill(2)));
|
|
131
|
+
const remoteB = createRemoteSessionAccount(createAccountId(new Uint8Array(32).fill(2)), new Uint8Array(32).fill(22));
|
|
132
|
+
const RemoteMsg = Struct({ id: str, kind: str, respondingTo: str, body: str });
|
|
133
|
+
const requestMsg = (id) => ({ id, kind: 'request', respondingTo: '', body: id });
|
|
134
|
+
function makeHost(adapter) {
|
|
135
|
+
return createSession({
|
|
136
|
+
localAccount: localA,
|
|
137
|
+
remoteAccount: remoteB,
|
|
138
|
+
statementStore: adapter,
|
|
139
|
+
encryption: mockEncryption(),
|
|
140
|
+
prover: mockProver,
|
|
141
|
+
sessionKey: SHARED_KEY,
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
function makeMobile(adapter) {
|
|
145
|
+
return createSession({
|
|
146
|
+
localAccount: localB,
|
|
147
|
+
remoteAccount: remoteA,
|
|
148
|
+
statementStore: adapter,
|
|
149
|
+
encryption: mockEncryption(),
|
|
150
|
+
prover: mockProver,
|
|
151
|
+
sessionKey: SHARED_KEY,
|
|
95
152
|
});
|
|
96
|
-
|
|
97
|
-
|
|
153
|
+
}
|
|
154
|
+
describe('session', () => {
|
|
155
|
+
const rawCodec = Bytes();
|
|
156
|
+
// On creation a session queries both of its topics, derives the starting expiry, and buffers
|
|
157
|
+
// anything it finds until it goes active. (Spec §5 initialization.)
|
|
158
|
+
describe('initialization', () => {
|
|
159
|
+
it('queries the outgoing and incoming topics on creation', async () => {
|
|
98
160
|
const { adapter } = makeSession();
|
|
99
161
|
await delay();
|
|
100
|
-
// Two single-topic matchAll queries — one per
|
|
101
|
-
// The topics must differ; otherwise both queries would target the same channel.
|
|
162
|
+
// Two single-topic matchAll queries — one per topic (outgoing/incoming); they must differ.
|
|
102
163
|
const topics = adapter.queryStatements.mock.calls.map(([f]) => f.matchAll);
|
|
103
164
|
expect(topics).toHaveLength(2);
|
|
104
165
|
expect(topics.map(t => t.length)).toEqual([1, 1]);
|
|
105
166
|
expect(topics[0]).not.toEqual(topics[1]);
|
|
106
167
|
});
|
|
107
|
-
it('
|
|
168
|
+
it('seeds the expiry from the highest own statement expiry', async () => {
|
|
108
169
|
const highExpiry = createExpiryFromDuration(7 * 24 * 60 * 60) + 9999n;
|
|
109
170
|
const ownRequest = makeStatement({ tag: 'request', value: { requestId: 'r1', data: [] } }, highExpiry);
|
|
110
|
-
const adapter =
|
|
111
|
-
let firstCall = true;
|
|
112
|
-
adapter.queryStatements.mockImplementation(() => {
|
|
113
|
-
if (firstCall) {
|
|
114
|
-
firstCall = false;
|
|
115
|
-
return okAsync([ownRequest]);
|
|
116
|
-
}
|
|
117
|
-
return okAsync([]);
|
|
118
|
-
});
|
|
119
|
-
const { session } = makeSession(adapter);
|
|
171
|
+
const { session, adapter } = makeSession({ own: [ownRequest] });
|
|
120
172
|
await delay();
|
|
121
|
-
//
|
|
122
|
-
const rawCodec = Bytes();
|
|
173
|
+
// The next submitted statement must carry an expiry greater than the highest seen.
|
|
123
174
|
void session.submitRequestMessage(rawCodec, new Uint8Array([1]));
|
|
124
175
|
await delay();
|
|
125
|
-
|
|
126
|
-
if (submittedStatement) {
|
|
127
|
-
expect(submittedStatement.expiry).toBeGreaterThan(highExpiry);
|
|
128
|
-
}
|
|
176
|
+
expect(lastSubmitted(adapter).expiry).toBeGreaterThan(highExpiry);
|
|
129
177
|
});
|
|
130
|
-
it('
|
|
178
|
+
it('buffers an incoming request found during init for a subscriber that registers later', async () => {
|
|
131
179
|
const peerRequest = makeStatement({ tag: 'request', value: { requestId: 'r2', data: [new Uint8Array([1])] } });
|
|
132
|
-
const
|
|
133
|
-
let callCount = 0;
|
|
134
|
-
adapter.queryStatements.mockImplementation(() => {
|
|
135
|
-
callCount++;
|
|
136
|
-
if (callCount === 2)
|
|
137
|
-
return okAsync([peerRequest]);
|
|
138
|
-
return okAsync([]);
|
|
139
|
-
});
|
|
140
|
-
const { session } = makeSession(adapter);
|
|
180
|
+
const { session } = makeSession({ peer: [peerRequest] });
|
|
141
181
|
await delay();
|
|
142
|
-
// Register subscriber AFTER init — buffered incoming request should be delivered
|
|
143
182
|
const callback = vi.fn();
|
|
144
|
-
session.subscribe(
|
|
183
|
+
session.subscribe(rawCodec, callback);
|
|
145
184
|
expect(callback).toHaveBeenCalled();
|
|
146
185
|
});
|
|
147
|
-
it('
|
|
186
|
+
it('queues messages submitted before init completes and sends them once active', async () => {
|
|
148
187
|
const { session, adapter } = makeSession();
|
|
149
|
-
//
|
|
188
|
+
// Submitted while still initializing → queued, not sent yet.
|
|
150
189
|
void session.submitRequestMessage(str, 'hello');
|
|
151
|
-
// Statement should NOT be submitted yet (still initializing)
|
|
152
190
|
expect(adapter.submitStatement).not.toHaveBeenCalled();
|
|
153
191
|
await delay();
|
|
154
|
-
// After init
|
|
192
|
+
// After init the queue is drained.
|
|
155
193
|
expect(adapter.submitStatement).toHaveBeenCalled();
|
|
156
194
|
});
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
const
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
195
|
+
it('does not regress its expiry counter when a response is submitted during init', async () => {
|
|
196
|
+
// A peer request auto-ACKed while init() is still in flight advances both state.expiry and
|
|
197
|
+
// the on-chain channel past the init query snapshot. init() must not reset the counter below
|
|
198
|
+
// that, or the next submit collides at an equal expiry (the single-writer drift).
|
|
199
|
+
let releaseInit;
|
|
200
|
+
const initBarrier = new Promise(resolve => {
|
|
201
|
+
releaseInit = resolve;
|
|
202
|
+
});
|
|
203
|
+
const queryStatements = vi.fn(() => ResultAsync.fromSafePromise(initBarrier.then(() => [])));
|
|
204
|
+
const { subscribeStatements, callbacks } = capturingSubscribe();
|
|
205
|
+
const { session, adapter } = makeSession({ queryStatements, subscribeStatements });
|
|
206
|
+
session.respondToRequests(rawCodec, () => 'success'); // activates the subscription + auto-ACK
|
|
207
|
+
// Two peer requests answered while init() is still pending → two response submits.
|
|
208
|
+
callbacks[0]({
|
|
209
|
+
statements: [makeStatement({ tag: 'request', value: { requestId: 'a', data: [new Uint8Array([1])] } })],
|
|
210
|
+
isComplete: true,
|
|
211
|
+
});
|
|
212
|
+
callbacks[0]({
|
|
213
|
+
statements: [makeStatement({ tag: 'request', value: { requestId: 'b', data: [new Uint8Array([2])] } })],
|
|
214
|
+
isComplete: true,
|
|
169
215
|
});
|
|
170
|
-
const { session } = makeSession(adapter);
|
|
171
216
|
await delay();
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
217
|
+
const inInitMax = adapter.submitStatement.mock.calls
|
|
218
|
+
.map(c => c[0].expiry ?? 0n)
|
|
219
|
+
.reduce((m, e) => (e > m ? e : m), 0n);
|
|
220
|
+
expect(inInitMax).toBeGreaterThan(0n); // sanity: responses really went out during init
|
|
221
|
+
releaseInit();
|
|
222
|
+
await delay();
|
|
223
|
+
// A response after init completes must use an expiry strictly above the in-init submits.
|
|
224
|
+
callbacks[0]({
|
|
225
|
+
statements: [makeStatement({ tag: 'request', value: { requestId: 'c', data: [new Uint8Array([3])] } })],
|
|
226
|
+
isComplete: true,
|
|
227
|
+
});
|
|
228
|
+
await delay();
|
|
229
|
+
const afterInit = (adapter.submitStatement.mock.calls.at(-1)?.[0]).expiry ?? 0n;
|
|
230
|
+
expect(afterInit).toBeGreaterThan(inInitMax);
|
|
231
|
+
session.dispose();
|
|
232
|
+
});
|
|
233
|
+
});
|
|
234
|
+
// On restart a session rebuilds its in-flight state from the on-chain statements. A request is
|
|
235
|
+
// answered by the PEER's response (read from our incoming topic); we have answered a peer request
|
|
236
|
+
// iff OUR response (on our outgoing topic) carries its id. (Spec §4 response placement + §5.)
|
|
237
|
+
describe('state restoration on restart', () => {
|
|
238
|
+
it('restores the outgoing request when it has no response yet', async () => {
|
|
239
|
+
const ownRequest = makeStatement({ tag: 'request', value: { requestId: 'saved-request-id', data: [] } });
|
|
240
|
+
const { session, adapter } = makeSession({ own: [ownRequest] }); // no peer response
|
|
241
|
+
await delay();
|
|
242
|
+
// If outgoingRequest was restored, a new message appends to it.
|
|
243
|
+
void session.submitRequestMessage(str, 'hello');
|
|
175
244
|
await delay();
|
|
176
245
|
expect(adapter.submitStatement).toHaveBeenCalled();
|
|
177
246
|
});
|
|
178
|
-
it('
|
|
179
|
-
|
|
180
|
-
const ownRequest = makeStatement({ tag: 'request', value: { requestId, data: [] } });
|
|
181
|
-
const peerResponse = makeStatement({ tag: 'response', value: { requestId, responseCode: 'success' } });
|
|
182
|
-
const adapter =
|
|
183
|
-
let callCount = 0;
|
|
184
|
-
adapter.queryStatements.mockImplementation(() => {
|
|
185
|
-
callCount++;
|
|
186
|
-
// Outgoing topic contains both our request AND the peer's response
|
|
187
|
-
if (callCount === 1)
|
|
188
|
-
return okAsync([ownRequest, peerResponse]);
|
|
189
|
-
return okAsync([]);
|
|
190
|
-
});
|
|
191
|
-
const { session } = makeSession(adapter);
|
|
247
|
+
it('does not restore the outgoing request once the peer has responded', async () => {
|
|
248
|
+
// Our request is on our outgoing topic; the peer's response to it is on our incoming topic.
|
|
249
|
+
const ownRequest = makeStatement({ tag: 'request', value: { requestId: 'or', data: [new Uint8Array([1])] } });
|
|
250
|
+
const peerResponse = makeStatement({ tag: 'response', value: { requestId: 'or', responseCode: 'success' } });
|
|
251
|
+
const { session, adapter } = makeSession({ own: [ownRequest], peer: [peerResponse] });
|
|
192
252
|
await delay();
|
|
193
|
-
//
|
|
194
|
-
|
|
195
|
-
void session.submitRequestMessage(codec, 'hi');
|
|
253
|
+
// Answered → not restored as pending → a new message starts a fresh batch (data length 1, not 2).
|
|
254
|
+
void session.submitRequestMessage(rawCodec, new Uint8Array([9]));
|
|
196
255
|
await delay();
|
|
197
|
-
|
|
198
|
-
expect(
|
|
256
|
+
const decoded = StatementData.dec(lastSubmitted(adapter).data);
|
|
257
|
+
expect(decoded.tag === 'request' && decoded.value.data.length).toBe(1);
|
|
258
|
+
session.dispose();
|
|
199
259
|
});
|
|
200
|
-
it('restores
|
|
260
|
+
it('restores an unanswered incoming request so it can still be answered', async () => {
|
|
201
261
|
const requestId = 'peer-request-id';
|
|
202
262
|
const peerRequest = makeStatement({ tag: 'request', value: { requestId, data: [] } });
|
|
203
|
-
const
|
|
204
|
-
let callCount = 0;
|
|
205
|
-
adapter.queryStatements.mockImplementation(() => {
|
|
206
|
-
callCount++;
|
|
207
|
-
if (callCount === 2)
|
|
208
|
-
return okAsync([peerRequest]);
|
|
209
|
-
return okAsync([]);
|
|
210
|
-
});
|
|
211
|
-
const { session } = makeSession(adapter);
|
|
263
|
+
const { session } = makeSession({ peer: [peerRequest] });
|
|
212
264
|
await delay();
|
|
213
|
-
// Calling submitResponseMessage with restored requestId should succeed
|
|
214
265
|
const result = await session.submitResponseMessage(requestId, 'success');
|
|
215
266
|
expect(result.isOk()).toBe(true);
|
|
216
267
|
});
|
|
217
|
-
it('
|
|
268
|
+
it('treats an incoming request as already answered when our response is present (no resubmit)', async () => {
|
|
218
269
|
const requestId = 'peer-request-id';
|
|
219
270
|
const peerRequest = makeStatement({ tag: 'request', value: { requestId, data: [] } });
|
|
220
271
|
const ownResponse = makeStatement({ tag: 'response', value: { requestId, responseCode: 'success' } });
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
adapter.queryStatements.mockImplementation(() => {
|
|
224
|
-
callCount++;
|
|
225
|
-
if (callCount === 1)
|
|
226
|
-
return okAsync([]);
|
|
227
|
-
// Incoming topic contains both the peer's request AND our response
|
|
228
|
-
return okAsync([peerRequest, ownResponse]);
|
|
229
|
-
});
|
|
230
|
-
const { session } = makeSession(adapter);
|
|
272
|
+
// The peer's request is on our incoming topic; OUR response to it is on our outgoing topic.
|
|
273
|
+
const { session, adapter } = makeSession({ own: [ownResponse], peer: [peerRequest] });
|
|
231
274
|
await delay();
|
|
232
|
-
// Already responded — submitResponseMessage should return ok without submitting again
|
|
233
275
|
const submitsBefore = adapter.submitStatement.mock.calls.length;
|
|
234
276
|
const result = await session.submitResponseMessage(requestId, 'success');
|
|
235
277
|
expect(result.isOk()).toBe(true);
|
|
236
278
|
expect(adapter.submitStatement.mock.calls.length).toBe(submitsBefore); // no new submit
|
|
237
279
|
});
|
|
280
|
+
it('does not re-deliver an incoming request we already answered', async () => {
|
|
281
|
+
const peerRequest = makeStatement({ tag: 'request', value: { requestId: 'pr', data: [new Uint8Array([1])] } });
|
|
282
|
+
const ownResponse = makeStatement({ tag: 'response', value: { requestId: 'pr', responseCode: 'success' } });
|
|
283
|
+
const { session } = makeSession({ own: [ownResponse], peer: [peerRequest] });
|
|
284
|
+
await delay();
|
|
285
|
+
const cb = vi.fn();
|
|
286
|
+
session.subscribe(rawCodec, cb);
|
|
287
|
+
expect(cb).not.toHaveBeenCalled(); // already responded → not re-delivered for re-processing
|
|
288
|
+
session.dispose();
|
|
289
|
+
});
|
|
290
|
+
});
|
|
291
|
+
// Outgoing messages are batched into one in-flight request; overflow is queued and sent only once
|
|
292
|
+
// the live request is answered. Identical messages are de-duplicated, order is preserved, and the
|
|
293
|
+
// batch is sized against the statement limit minus the fixed wire overhead. (Spec §6.)
|
|
294
|
+
describe('sending requests', () => {
|
|
295
|
+
it('sends a single statement for the first message', async () => {
|
|
296
|
+
const { session, adapter } = makeSession();
|
|
297
|
+
await delay();
|
|
298
|
+
void session.submitRequestMessage(rawCodec, new Uint8Array([1, 2, 3]));
|
|
299
|
+
await delay();
|
|
300
|
+
expect(adapter.submitStatement).toHaveBeenCalledTimes(1);
|
|
301
|
+
});
|
|
302
|
+
it('appends a second message to the in-flight batch and resubmits', async () => {
|
|
303
|
+
const { session, adapter } = makeSession();
|
|
304
|
+
await delay();
|
|
305
|
+
void session.submitRequestMessage(rawCodec, new Uint8Array([1]));
|
|
306
|
+
void session.submitRequestMessage(rawCodec, new Uint8Array([2]));
|
|
307
|
+
await delay();
|
|
308
|
+
// Two submits: first for msg1, second for the msg1+msg2 batch.
|
|
309
|
+
expect(adapter.submitStatement).toHaveBeenCalledTimes(2);
|
|
310
|
+
});
|
|
311
|
+
it('sizes a batch by its full encoded payload, not the raw message bytes', async () => {
|
|
312
|
+
const m1 = rawCodec.enc(new Uint8Array([1, 2, 3]));
|
|
313
|
+
const m2 = rawCodec.enc(new Uint8Array([4, 5, 6]));
|
|
314
|
+
// Budget fits a one-message request but not a two-message one — even though the raw
|
|
315
|
+
// message bytes are tiny, the requestId + SCALE framing tip the second over the limit.
|
|
316
|
+
const maxRequestSize = STATEMENT_OVERHEAD + reqPayloadSize(m1, m2) - 1;
|
|
317
|
+
expect(m1.length + m2.length).toBeLessThan(reqPayloadSize(m1)); // raw sum < a single-message payload
|
|
318
|
+
const { session, adapter } = makeSession({ maxRequestSize });
|
|
319
|
+
await delay();
|
|
320
|
+
void session.submitRequestMessage(rawCodec, new Uint8Array([1, 2, 3]));
|
|
321
|
+
void session.submitRequestMessage(rawCodec, new Uint8Array([4, 5, 6])); // raw sum fits, full payload doesn't
|
|
322
|
+
await delay();
|
|
323
|
+
// The second message is queued, not appended → only the first batch was submitted.
|
|
324
|
+
expect(adapter.submitStatement).toHaveBeenCalledTimes(1);
|
|
325
|
+
});
|
|
326
|
+
it('drains the queue after the in-flight batch is answered', async () => {
|
|
327
|
+
const { subscribeStatements, callbacks } = capturingSubscribe();
|
|
328
|
+
const m1 = rawCodec.enc(new Uint8Array([1, 2, 3]));
|
|
329
|
+
const m2 = rawCodec.enc(new Uint8Array([4, 5, 6]));
|
|
330
|
+
const maxRequestSize = STATEMENT_OVERHEAD + reqPayloadSize(m1, m2) - 1; // m1 fits, m1+m2 doesn't
|
|
331
|
+
const { session, adapter } = makeSession({ maxRequestSize, subscribeStatements });
|
|
332
|
+
await delay();
|
|
333
|
+
session.subscribe(Bytes(), vi.fn()); // ensure the store subscription is active
|
|
334
|
+
void session.submitRequestMessage(rawCodec, new Uint8Array([1, 2, 3])); // sent
|
|
335
|
+
void session.submitRequestMessage(rawCodec, new Uint8Array([4, 5, 6])); // queued (doesn't fit)
|
|
336
|
+
await delay();
|
|
337
|
+
const submitCountBefore = adapter.submitStatement.mock.calls.length;
|
|
338
|
+
// Peer responds to the live request (responses arrive on our incoming topic).
|
|
339
|
+
const responseStatement = makeStatement({
|
|
340
|
+
tag: 'response',
|
|
341
|
+
value: { requestId: lastSubmittedRequestId(adapter), responseCode: 'success' },
|
|
342
|
+
});
|
|
343
|
+
callbacks[0]({ statements: [responseStatement], isComplete: true });
|
|
344
|
+
await delay();
|
|
345
|
+
// The queued message is now submitted.
|
|
346
|
+
expect(adapter.submitStatement.mock.calls.length).toBeGreaterThan(submitCountBefore);
|
|
347
|
+
});
|
|
348
|
+
it('resolves waitForResponseMessage when the batch is answered', async () => {
|
|
349
|
+
const { subscribeStatements, callbacks } = capturingSubscribe();
|
|
350
|
+
const { session, adapter } = makeSession({ subscribeStatements });
|
|
351
|
+
await delay();
|
|
352
|
+
session.subscribe(Bytes(), vi.fn()); // ensure the store subscription is active
|
|
353
|
+
const submitResult = await session.submitRequestMessage(rawCodec, new Uint8Array([1]));
|
|
354
|
+
const token = submitResult.unwrapOr({ requestId: '' }).requestId;
|
|
355
|
+
await delay();
|
|
356
|
+
const responsePromise = session.waitForResponseMessage(token);
|
|
357
|
+
callbacks[0]({
|
|
358
|
+
statements: [
|
|
359
|
+
makeStatement({
|
|
360
|
+
tag: 'response',
|
|
361
|
+
value: { requestId: lastSubmittedRequestId(adapter), responseCode: 'success' },
|
|
362
|
+
}),
|
|
363
|
+
],
|
|
364
|
+
isComplete: true,
|
|
365
|
+
});
|
|
366
|
+
await delay();
|
|
367
|
+
const result = await responsePromise;
|
|
368
|
+
expect(result.isOk()).toBe(true);
|
|
369
|
+
expect(result.unwrapOr({ responseCode: 'unknown' }).responseCode).toBe('success');
|
|
370
|
+
});
|
|
371
|
+
it('does not resend a message that is already in flight (dedup)', async () => {
|
|
372
|
+
const store = createInMemoryStatementStore();
|
|
373
|
+
const session = makeHost(store);
|
|
374
|
+
await settle();
|
|
375
|
+
const msg = new Uint8Array([1, 2, 3]);
|
|
376
|
+
void session.submitRequestMessage(rawCodec, msg);
|
|
377
|
+
await settle();
|
|
378
|
+
const acceptedAfterFirst = store.acceptedStatements().length;
|
|
379
|
+
void session.submitRequestMessage(rawCodec, msg); // identical → must not resubmit
|
|
380
|
+
await settle();
|
|
381
|
+
expect(store.acceptedStatements().length).toBe(acceptedAfterFirst);
|
|
382
|
+
session.dispose();
|
|
383
|
+
});
|
|
384
|
+
it('resolves every caller of a deduplicated message on the single response', async () => {
|
|
385
|
+
const { subscribeStatements, callbacks } = capturingSubscribe();
|
|
386
|
+
const { session, adapter } = makeSession({ subscribeStatements });
|
|
387
|
+
await delay();
|
|
388
|
+
session.subscribe(rawCodec, vi.fn());
|
|
389
|
+
const r1 = await session.submitRequestMessage(rawCodec, new Uint8Array([7, 7]));
|
|
390
|
+
const r2 = await session.submitRequestMessage(rawCodec, new Uint8Array([7, 7])); // duplicate
|
|
391
|
+
const w1 = session.waitForResponseMessage(r1._unsafeUnwrap().requestId);
|
|
392
|
+
const w2 = session.waitForResponseMessage(r2._unsafeUnwrap().requestId);
|
|
393
|
+
await delay();
|
|
394
|
+
callbacks[0]({
|
|
395
|
+
statements: [
|
|
396
|
+
makeStatement({
|
|
397
|
+
tag: 'response',
|
|
398
|
+
value: { requestId: lastSubmittedRequestId(adapter), responseCode: 'success' },
|
|
399
|
+
}),
|
|
400
|
+
],
|
|
401
|
+
isComplete: true,
|
|
402
|
+
});
|
|
403
|
+
await delay();
|
|
404
|
+
expect((await w1).isOk()).toBe(true);
|
|
405
|
+
expect((await w2).isOk()).toBe(true);
|
|
406
|
+
});
|
|
407
|
+
it('preserves FIFO order: a later fitting message does not overtake queued ones', async () => {
|
|
408
|
+
const m1 = rawCodec.enc(new Uint8Array([1, 2, 3])); // first → in-flight batch
|
|
409
|
+
const mBig = rawCodec.enc(new Uint8Array([4, 5, 6, 7, 8, 9, 10])); // does not fit alongside m1 → queued
|
|
410
|
+
const mSmall = rawCodec.enc(new Uint8Array([9])); // would fit alongside m1, but must queue behind mBig
|
|
411
|
+
const maxRequestSize = STATEMENT_OVERHEAD + reqPayloadSize(m1, mBig) - 1;
|
|
412
|
+
expect(reqPayloadSize(m1, mSmall)).toBeLessThanOrEqual(reqPayloadSize(m1, mBig) - 1); // mSmall alone could fit
|
|
413
|
+
const { session, adapter } = makeSession({ maxRequestSize });
|
|
414
|
+
await delay();
|
|
415
|
+
void session.submitRequestMessage(rawCodec, new Uint8Array([1, 2, 3]));
|
|
416
|
+
void session.submitRequestMessage(rawCodec, new Uint8Array([4, 5, 6, 7, 8, 9, 10]));
|
|
417
|
+
void session.submitRequestMessage(rawCodec, new Uint8Array([9]));
|
|
418
|
+
await delay();
|
|
419
|
+
const decoded = StatementData.dec(lastSubmitted(adapter).data);
|
|
420
|
+
expect(decoded.tag === 'request' && decoded.value.data.length).toBe(1); // still only the first message
|
|
421
|
+
});
|
|
422
|
+
it('rejects a message whose full request payload exceeds the limit', async () => {
|
|
423
|
+
const m = rawCodec.enc(new Uint8Array([1, 2, 3, 4]));
|
|
424
|
+
// The single-message request payload (requestId + framing + the message) is over budget.
|
|
425
|
+
const { session, adapter } = makeSession({ maxRequestSize: STATEMENT_OVERHEAD + reqPayloadSize(m) - 1 });
|
|
426
|
+
await delay();
|
|
427
|
+
adapter.submitStatement.mockClear();
|
|
428
|
+
const result = await session.submitRequestMessage(rawCodec, new Uint8Array([1, 2, 3, 4]));
|
|
429
|
+
expect(result.isErr()).toBe(true);
|
|
430
|
+
expect(adapter.submitStatement).not.toHaveBeenCalled();
|
|
431
|
+
});
|
|
238
432
|
});
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
433
|
+
// A single subscription on the incoming topic carries both peer requests and peer responses.
|
|
434
|
+
// Requests are buffered so a subscriber registering after delivery still receives them; already
|
|
435
|
+
// seen statements are dropped. (Spec §4 reading + §6 dedup.)
|
|
436
|
+
describe('receiving statements', () => {
|
|
437
|
+
it('delivers a buffered incoming request to a subscriber that registers after init', async () => {
|
|
242
438
|
const requestId = 'incoming-req';
|
|
243
439
|
const peerRequest = makeStatement({ tag: 'request', value: { requestId, data: [new Uint8Array([1, 2, 3])] } });
|
|
244
|
-
const
|
|
245
|
-
let callCount = 0;
|
|
246
|
-
adapter.queryStatements.mockImplementation(() => {
|
|
247
|
-
callCount++;
|
|
248
|
-
if (callCount === 2)
|
|
249
|
-
return okAsync([peerRequest]);
|
|
250
|
-
return okAsync([]);
|
|
251
|
-
});
|
|
252
|
-
const { session } = makeSession(adapter);
|
|
440
|
+
const { session } = makeSession({ peer: [peerRequest] });
|
|
253
441
|
await delay(); // init completes
|
|
254
442
|
const callback = vi.fn();
|
|
255
443
|
session.subscribe(rawCodec, callback);
|
|
@@ -258,18 +446,10 @@ describe('session', () => {
|
|
|
258
446
|
expect(messages[0]?.type).toBe('request');
|
|
259
447
|
expect(messages[0]?.requestId).toBe(requestId);
|
|
260
448
|
});
|
|
261
|
-
it('delivers
|
|
449
|
+
it('delivers a buffered incoming request to a subscriber that registers before init completes', async () => {
|
|
262
450
|
const requestId = 'early-subscribe';
|
|
263
451
|
const peerRequest = makeStatement({ tag: 'request', value: { requestId, data: [new Uint8Array([1])] } });
|
|
264
|
-
const
|
|
265
|
-
let callCount = 0;
|
|
266
|
-
adapter.queryStatements.mockImplementation(() => {
|
|
267
|
-
callCount++;
|
|
268
|
-
if (callCount === 2)
|
|
269
|
-
return okAsync([peerRequest]);
|
|
270
|
-
return okAsync([]);
|
|
271
|
-
});
|
|
272
|
-
const { session } = makeSession(adapter);
|
|
452
|
+
const { session } = makeSession({ peer: [peerRequest] });
|
|
273
453
|
const callback = vi.fn();
|
|
274
454
|
session.subscribe(rawCodec, callback); // before init completes
|
|
275
455
|
await delay();
|
|
@@ -277,278 +457,334 @@ describe('session', () => {
|
|
|
277
457
|
const messages2 = callback.mock.calls[0][0];
|
|
278
458
|
expect(messages2[0]?.requestId).toBe(requestId);
|
|
279
459
|
});
|
|
280
|
-
it('does
|
|
460
|
+
it('does not redeliver an already-seen statement', async () => {
|
|
281
461
|
const requestId = 'seen-req';
|
|
282
462
|
const peerRequest = makeStatement({ tag: 'request', value: { requestId, data: [new Uint8Array([1])] } });
|
|
283
|
-
const
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
queryCallCount++;
|
|
287
|
-
if (queryCallCount === 2)
|
|
288
|
-
return okAsync([peerRequest]);
|
|
289
|
-
return okAsync([]);
|
|
290
|
-
});
|
|
291
|
-
let subscribeCallback;
|
|
292
|
-
adapter.subscribeStatements.mockImplementation((_filter, cb) => {
|
|
293
|
-
subscribeCallback = cb;
|
|
294
|
-
return vi.fn();
|
|
295
|
-
});
|
|
296
|
-
const { session } = makeSession(adapter);
|
|
297
|
-
await delay(); // init sees peerRequest, adds to seenStatements
|
|
463
|
+
const { subscribeStatements, callbacks } = capturingSubscribe();
|
|
464
|
+
const { session } = makeSession({ peer: [peerRequest], subscribeStatements });
|
|
465
|
+
await delay(); // init sees peerRequest, adds it to seenStatements
|
|
298
466
|
const appCallback = vi.fn();
|
|
299
467
|
session.subscribe(rawCodec, appCallback);
|
|
300
|
-
//
|
|
301
|
-
|
|
468
|
+
// The store redelivers the same statement on the incoming topic — dedup must drop it.
|
|
469
|
+
callbacks[0]({ statements: [peerRequest], isComplete: true });
|
|
302
470
|
await delay();
|
|
303
|
-
//
|
|
471
|
+
// Called only once (from the buffered init message), not again from the subscription.
|
|
304
472
|
expect(appCallback).toHaveBeenCalledTimes(1);
|
|
305
473
|
});
|
|
306
|
-
it('does
|
|
307
|
-
const
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
const subscribeCallbacks = [];
|
|
311
|
-
adapter.subscribeStatements.mockImplementation((_filter, cb) => {
|
|
312
|
-
subscribeCallbacks.push(cb);
|
|
313
|
-
return vi.fn();
|
|
474
|
+
it('does not auto-respond when an incoming request arrives', async () => {
|
|
475
|
+
const peerRequest = makeStatement({
|
|
476
|
+
tag: 'request',
|
|
477
|
+
value: { requestId: 'no-auto-resp', data: [new Uint8Array([1])] },
|
|
314
478
|
});
|
|
315
|
-
|
|
316
|
-
const { session } = makeSession(
|
|
479
|
+
const { subscribeStatements, callbacks } = capturingSubscribe();
|
|
480
|
+
const { session, adapter } = makeSession({ subscribeStatements });
|
|
317
481
|
await delay();
|
|
318
482
|
const callback = vi.fn();
|
|
319
483
|
session.subscribe(rawCodec, callback);
|
|
320
484
|
adapter.submitStatement.mockClear();
|
|
321
|
-
|
|
322
|
-
subscribeCallbacks[0]({ statements: [peerRequest], isComplete: true });
|
|
485
|
+
callbacks[0]({ statements: [peerRequest], isComplete: true });
|
|
323
486
|
await delay();
|
|
324
|
-
//
|
|
487
|
+
// Delivered to the app, but no response is submitted automatically.
|
|
325
488
|
expect(callback).toHaveBeenCalled();
|
|
326
489
|
expect(adapter.submitStatement).not.toHaveBeenCalled();
|
|
327
490
|
});
|
|
328
|
-
it('delivers
|
|
329
|
-
//
|
|
330
|
-
//
|
|
331
|
-
//
|
|
332
|
-
|
|
333
|
-
// after waitForResponseMessage resolves) still receive them.
|
|
334
|
-
const subscribeCallbacks = [];
|
|
335
|
-
const subscribeStatements = vi.fn().mockImplementation((_filter, cb) => {
|
|
336
|
-
subscribeCallbacks.push(cb);
|
|
337
|
-
return vi.fn();
|
|
338
|
-
});
|
|
491
|
+
it('delivers a buffered request to a subscriber that registers after the batch notification', async () => {
|
|
492
|
+
// When a request and an ACK arrive in the same batch, the request is processed before a later
|
|
493
|
+
// waitForRequestMessage registers its subscriber. Requests are buffered so the late subscriber
|
|
494
|
+
// still receives them (otherwise waitForRequestMessage would hang).
|
|
495
|
+
const { subscribeStatements, callbacks } = capturingSubscribe();
|
|
339
496
|
const { session } = makeSession({ subscribeStatements });
|
|
340
497
|
await delay();
|
|
341
|
-
//
|
|
342
|
-
// any pre-existing subscriber in the session, e.g. the app listening for messages).
|
|
498
|
+
// A pre-existing subscriber activates the store subscription.
|
|
343
499
|
const dummyUnsub = session.subscribe(rawCodec, vi.fn());
|
|
344
500
|
const peerRequestId = 'race-condition-request';
|
|
345
501
|
const peerRequest = makeStatement({
|
|
346
502
|
tag: 'request',
|
|
347
503
|
value: { requestId: peerRequestId, data: [new Uint8Array([42])] },
|
|
348
504
|
});
|
|
349
|
-
//
|
|
350
|
-
|
|
351
|
-
// subscriber yet (the race condition scenario).
|
|
352
|
-
subscribeCallbacks[0]({ statements: [peerRequest], isComplete: true });
|
|
505
|
+
// The request arrives before the late subscriber registers.
|
|
506
|
+
callbacks[0]({ statements: [peerRequest], isComplete: true });
|
|
353
507
|
await delay();
|
|
354
|
-
// Now the late subscriber registers (simulates waitForRequestMessage being called
|
|
355
|
-
// in the .andThen() chain after waitForResponseMessage resolves).
|
|
356
508
|
const lateCallback = vi.fn();
|
|
357
509
|
session.subscribe(rawCodec, lateCallback);
|
|
358
|
-
// The late subscriber must receive the buffered peer request, otherwise
|
|
359
|
-
// waitForRequestMessage would hang indefinitely.
|
|
360
510
|
expect(lateCallback).toHaveBeenCalledTimes(1);
|
|
361
511
|
const messages = lateCallback.mock.calls[0][0];
|
|
362
512
|
expect(messages[0]?.type).toBe('request');
|
|
363
513
|
expect(messages[0]?.requestId).toBe(peerRequestId);
|
|
364
514
|
dummyUnsub();
|
|
365
515
|
});
|
|
366
|
-
it('
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
516
|
+
it('resolves waitForRequestMessage from a request already buffered at subscribe time', async () => {
|
|
517
|
+
// The subscribe() replay can invoke the filter synchronously during registration; the
|
|
518
|
+
// unsubscribe handle must already be usable at that point.
|
|
519
|
+
// The inner payload must decode cleanly with rawCodec so the filter actually runs.
|
|
520
|
+
const peerRequest = makeStatement({
|
|
521
|
+
tag: 'request',
|
|
522
|
+
value: { requestId: 'buf', data: [rawCodec.enc(new Uint8Array([7]))] },
|
|
523
|
+
});
|
|
524
|
+
const { session } = makeSession({ peer: [peerRequest] });
|
|
525
|
+
await delay(); // init buffers the peer request
|
|
526
|
+
const result = await session.waitForRequestMessage(rawCodec, () => 'matched');
|
|
527
|
+
expect(result._unsafeUnwrap()).toBe('matched');
|
|
528
|
+
}, 2000);
|
|
529
|
+
it('opens a single subscription on the incoming topic', () => {
|
|
530
|
+
const store = createInMemoryStatementStore();
|
|
531
|
+
const session = makeHost(store);
|
|
378
532
|
session.subscribe(rawCodec, vi.fn());
|
|
379
|
-
//
|
|
380
|
-
expect(
|
|
533
|
+
// One subscription: the incoming topic carries both peer requests and peer responses.
|
|
534
|
+
expect(store.activeSubscriptions()).toBe(1);
|
|
381
535
|
});
|
|
382
|
-
it('tears down
|
|
383
|
-
const
|
|
536
|
+
it('tears down the subscription when the last subscriber leaves', () => {
|
|
537
|
+
const store = createInMemoryStatementStore();
|
|
538
|
+
const session = makeHost(store);
|
|
384
539
|
const unsub = session.subscribe(rawCodec, vi.fn());
|
|
540
|
+
expect(store.activeSubscriptions()).toBe(1);
|
|
385
541
|
unsub();
|
|
386
|
-
|
|
387
|
-
for (const result of adapter.subscribeStatements.mock.results) {
|
|
388
|
-
const mockUnsub = result.value;
|
|
389
|
-
expect(mockUnsub).toHaveBeenCalled();
|
|
390
|
-
}
|
|
542
|
+
expect(store.activeSubscriptions()).toBe(0);
|
|
391
543
|
});
|
|
392
|
-
it('delivers peer response
|
|
393
|
-
const
|
|
394
|
-
const subscribeStatements = vi.fn().mockImplementation((_topics, cb) => {
|
|
395
|
-
subscribeCallbacks.push(cb);
|
|
396
|
-
return vi.fn();
|
|
397
|
-
});
|
|
544
|
+
it('delivers a peer response to subscribers', async () => {
|
|
545
|
+
const { subscribeStatements, callbacks } = capturingSubscribe();
|
|
398
546
|
const { session, adapter } = makeSession({ subscribeStatements });
|
|
399
547
|
await delay();
|
|
400
|
-
|
|
401
|
-
void session.submitRequestMessage(rawCodec, new Uint8Array([1]));
|
|
548
|
+
void session.submitRequestMessage(rawCodec, new Uint8Array([1])); // creates an outgoing request
|
|
402
549
|
await delay();
|
|
403
|
-
|
|
404
|
-
const submitted = adapter.submitStatement.mock.calls[0][0];
|
|
405
|
-
const decoded = StatementData.dec(submitted.data);
|
|
406
|
-
const requestId = decoded.tag === 'request' ? decoded.value.requestId : '';
|
|
550
|
+
const requestId = lastSubmittedRequestId(adapter);
|
|
407
551
|
const callback = vi.fn();
|
|
408
552
|
session.subscribe(rawCodec, callback);
|
|
409
|
-
// Deliver the response via the SECOND subscription callback (outgoing topic)
|
|
410
|
-
// subscribeCallbacks[0] = incoming topic, subscribeCallbacks[1] = outgoing topic
|
|
411
553
|
const responseStatement = makeStatement({
|
|
412
554
|
tag: 'response',
|
|
413
555
|
value: { requestId, responseCode: 'success' },
|
|
414
556
|
});
|
|
415
|
-
|
|
557
|
+
callbacks[0]({ statements: [responseStatement], isComplete: true });
|
|
416
558
|
await delay();
|
|
417
|
-
// Subscriber should receive the response
|
|
418
559
|
const allCalls = callback.mock.calls.flat();
|
|
419
560
|
const responseMessages = allCalls.flat().filter((m) => m.type === 'response');
|
|
420
561
|
expect(responseMessages.length).toBeGreaterThan(0);
|
|
421
562
|
});
|
|
422
|
-
it('
|
|
423
|
-
const
|
|
424
|
-
const
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
// Deliver a request via the outgoing topic subscription (would be our own echoed back)
|
|
434
|
-
const ownRequest = makeStatement({
|
|
435
|
-
tag: 'request',
|
|
436
|
-
value: { requestId: 'own-req', data: [new Uint8Array([1])] },
|
|
437
|
-
});
|
|
438
|
-
subscribeCallbacks[1]({ statements: [ownRequest], isComplete: true });
|
|
439
|
-
await delay();
|
|
440
|
-
// Should NOT be delivered to subscriber (filtered by responsesOnly flag)
|
|
441
|
-
expect(callback).not.toHaveBeenCalled();
|
|
563
|
+
it('stops replaying a buffered request to new subscribers once it has been answered', async () => {
|
|
564
|
+
const peerRequest = makeStatement({ tag: 'request', value: { requestId: 'q', data: [new Uint8Array([1])] } });
|
|
565
|
+
const { session } = makeSession({ peer: [peerRequest] });
|
|
566
|
+
await delay(); // init buffers the unanswered request for replay
|
|
567
|
+
await session.submitResponseMessage('q', 'success'); // answer it
|
|
568
|
+
// A subscriber registering afterwards must not be handed the already-answered request
|
|
569
|
+
// (and the buffer must not retain it forever).
|
|
570
|
+
const late = vi.fn();
|
|
571
|
+
session.subscribe(rawCodec, late);
|
|
572
|
+
expect(late).not.toHaveBeenCalled();
|
|
573
|
+
session.dispose();
|
|
442
574
|
});
|
|
443
575
|
});
|
|
444
|
-
|
|
445
|
-
|
|
576
|
+
// We answer the peer's requests by publishing a response on OUR outgoing topic/response-channel.
|
|
577
|
+
// submitResponseMessage is the low-level primitive; respondToRequests auto-answers from a handler.
|
|
578
|
+
// (Spec §4 response placement.)
|
|
579
|
+
describe('responding to incoming requests', () => {
|
|
580
|
+
it('is idempotent — a second response does not submit again', async () => {
|
|
446
581
|
const requestId = 'req-to-respond';
|
|
447
582
|
const peerRequest = makeStatement({ tag: 'request', value: { requestId, data: [] } });
|
|
448
|
-
const adapter =
|
|
449
|
-
let callCount = 0;
|
|
450
|
-
adapter.queryStatements.mockImplementation(() => {
|
|
451
|
-
callCount++;
|
|
452
|
-
if (callCount === 2)
|
|
453
|
-
return okAsync([peerRequest]);
|
|
454
|
-
return okAsync([]);
|
|
455
|
-
});
|
|
456
|
-
const { session } = makeSession(adapter);
|
|
583
|
+
const { session, adapter } = makeSession({ peer: [peerRequest] });
|
|
457
584
|
await delay();
|
|
458
585
|
await session.submitResponseMessage(requestId, 'success');
|
|
459
586
|
const submitsAfterFirst = adapter.submitStatement.mock.calls.length;
|
|
460
587
|
await session.submitResponseMessage(requestId, 'success'); // second call
|
|
461
588
|
expect(adapter.submitStatement.mock.calls.length).toBe(submitsAfterFirst);
|
|
462
589
|
});
|
|
463
|
-
it('
|
|
590
|
+
it('errors when the requestId is unknown', async () => {
|
|
464
591
|
const { session } = makeSession();
|
|
465
592
|
await delay();
|
|
466
593
|
const result = await session.submitResponseMessage('wrong-id', 'success');
|
|
467
594
|
expect(result.isErr()).toBe(true);
|
|
468
595
|
});
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
const
|
|
596
|
+
it('NACKs an undecodable incoming request with decodingFailed', async () => {
|
|
597
|
+
// A request (enum tag 0) whose requestId decodes but whose data vector claims more
|
|
598
|
+
// elements than present → the body decode throws, the requestId is still recoverable.
|
|
599
|
+
const idBytes = new TextEncoder().encode('corrupt-rid');
|
|
600
|
+
const corrupted = new Uint8Array([0x00, idBytes.length << 2, ...idBytes, 0xfe, 0xff, 0xff, 0xff]);
|
|
601
|
+
const statement = { ...makeStatement({ tag: 'request', value: { requestId: 'x', data: [] } }), data: corrupted };
|
|
602
|
+
const { subscribeStatements, callbacks } = capturingSubscribe();
|
|
603
|
+
const { session, adapter } = makeSession({ subscribeStatements });
|
|
474
604
|
await delay();
|
|
475
|
-
|
|
605
|
+
session.subscribe(rawCodec, vi.fn()); // activate the subscription
|
|
606
|
+
adapter.submitStatement.mockClear();
|
|
607
|
+
callbacks[0]({ statements: [statement], isComplete: true });
|
|
476
608
|
await delay();
|
|
477
609
|
expect(adapter.submitStatement).toHaveBeenCalledTimes(1);
|
|
610
|
+
const decoded = StatementData.dec(lastSubmitted(adapter).data);
|
|
611
|
+
expect(decoded.tag).toBe('response');
|
|
612
|
+
if (decoded.tag === 'response') {
|
|
613
|
+
expect(decoded.value.requestId).toBe('corrupt-rid');
|
|
614
|
+
expect(decoded.value.responseCode).toBe('decodingFailed');
|
|
615
|
+
}
|
|
478
616
|
});
|
|
479
|
-
it('
|
|
480
|
-
|
|
617
|
+
it('drops an undecodable incoming statement with no recoverable request id', async () => {
|
|
618
|
+
// An invalid enum tag — nothing decodes, so there is no id to NACK.
|
|
619
|
+
const corrupted = new Uint8Array([0x05, 0x2c, 1, 2, 3]);
|
|
620
|
+
const statement = { ...makeStatement({ tag: 'request', value: { requestId: 'x', data: [] } }), data: corrupted };
|
|
621
|
+
const { subscribeStatements, callbacks } = capturingSubscribe();
|
|
622
|
+
const { session, adapter } = makeSession({ subscribeStatements });
|
|
481
623
|
await delay();
|
|
482
|
-
|
|
483
|
-
|
|
624
|
+
session.subscribe(rawCodec, vi.fn());
|
|
625
|
+
adapter.submitStatement.mockClear();
|
|
626
|
+
callbacks[0]({ statements: [statement], isComplete: true });
|
|
484
627
|
await delay();
|
|
485
|
-
|
|
486
|
-
expect(adapter.submitStatement).toHaveBeenCalledTimes(2);
|
|
628
|
+
expect(adapter.submitStatement).not.toHaveBeenCalled();
|
|
487
629
|
});
|
|
488
|
-
it('
|
|
489
|
-
|
|
630
|
+
it('does not NACK an undecodable copy of a request it already knows', async () => {
|
|
631
|
+
// A valid request is being handled; a corrupt copy of it must not trigger a premature
|
|
632
|
+
// decodingFailed that would mask the real response (the `responded` flag is sticky).
|
|
633
|
+
const reqId = 'known-req';
|
|
634
|
+
const valid = makeStatement({ tag: 'request', value: { requestId: reqId, data: [new Uint8Array([1])] } });
|
|
635
|
+
const idBytes = new TextEncoder().encode(reqId);
|
|
636
|
+
const corrupt = new Uint8Array([0x00, idBytes.length << 2, ...idBytes, 0xfe, 0xff, 0xff, 0xff]);
|
|
637
|
+
const corruptStatement = {
|
|
638
|
+
...makeStatement({ tag: 'request', value: { requestId: 'x', data: [] } }),
|
|
639
|
+
data: corrupt,
|
|
640
|
+
};
|
|
641
|
+
const { subscribeStatements, callbacks } = capturingSubscribe();
|
|
642
|
+
const { session, adapter } = makeSession({ subscribeStatements });
|
|
490
643
|
await delay();
|
|
491
|
-
|
|
492
|
-
|
|
644
|
+
session.subscribe(rawCodec, vi.fn()); // activate the subscription
|
|
645
|
+
callbacks[0]({ statements: [valid], isComplete: true }); // reqId is now a known incoming request
|
|
493
646
|
await delay();
|
|
494
|
-
|
|
495
|
-
|
|
647
|
+
adapter.submitStatement.mockClear();
|
|
648
|
+
callbacks[0]({ statements: [corruptStatement], isComplete: true }); // corrupt copy of the same id
|
|
649
|
+
await delay();
|
|
650
|
+
expect(adapter.submitStatement).not.toHaveBeenCalled(); // no premature NACK
|
|
651
|
+
// The legitimate response still goes through.
|
|
652
|
+
const res = await session.submitResponseMessage(reqId, 'success');
|
|
653
|
+
expect(res.isOk()).toBe(true);
|
|
654
|
+
const decoded = StatementData.dec(lastSubmitted(adapter).data);
|
|
655
|
+
expect(decoded.tag === 'response' && decoded.value.responseCode).toBe('success');
|
|
496
656
|
});
|
|
497
|
-
it('
|
|
498
|
-
|
|
499
|
-
const
|
|
500
|
-
subscribeCallback = cb;
|
|
501
|
-
return vi.fn();
|
|
502
|
-
});
|
|
503
|
-
const { session, adapter } = makeSession({ maxRequestSize: 5, subscribeStatements });
|
|
657
|
+
it('publishes the response on the outgoing topic (same topic as our requests)', async () => {
|
|
658
|
+
const peerRequest = makeStatement({ tag: 'request', value: { requestId: 'rid', data: [] } });
|
|
659
|
+
const { session, adapter } = makeSession({ peer: [peerRequest] });
|
|
504
660
|
await delay();
|
|
505
|
-
session.
|
|
506
|
-
void session.submitRequestMessage(rawCodec, new Uint8Array([1, 2, 3])); // sent
|
|
507
|
-
void session.submitRequestMessage(rawCodec, new Uint8Array([4, 5, 6])); // queued (doesn't fit)
|
|
661
|
+
void session.submitRequestMessage(rawCodec, new Uint8Array([1])); // → outgoing topic
|
|
508
662
|
await delay();
|
|
509
|
-
const
|
|
510
|
-
|
|
511
|
-
//
|
|
512
|
-
const
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
663
|
+
const requestTopics = lastSubmitted(adapter).topics;
|
|
664
|
+
adapter.submitStatement.mockClear();
|
|
665
|
+
await session.submitResponseMessage('rid', 'success'); // must also go on the outgoing topic
|
|
666
|
+
const responseTopics = lastSubmitted(adapter).topics;
|
|
667
|
+
expect(responseTopics).toEqual(requestTopics);
|
|
668
|
+
});
|
|
669
|
+
// respondToRequests is exercised end-to-end over a shared in-memory store: a peer issues a real
|
|
670
|
+
// request and the handler's status flows all the way back through request()/mapResponseCode.
|
|
671
|
+
it('acknowledges an incoming request with the status the handler returns', async () => {
|
|
672
|
+
const store = createInMemoryStatementStore();
|
|
673
|
+
const host = makeHost(store);
|
|
674
|
+
const peer = makeMobile(store);
|
|
675
|
+
peer.subscribe(RemoteMsg, () => undefined); // peer must listen to receive the ACK
|
|
676
|
+
host.respondToRequests(RemoteMsg, () => 'success');
|
|
677
|
+
await settle();
|
|
678
|
+
const ack = await peer.request(RemoteMsg, requestMsg('p1'));
|
|
679
|
+
expect(ack.isOk()).toBe(true);
|
|
680
|
+
host.dispose();
|
|
681
|
+
peer.dispose();
|
|
682
|
+
});
|
|
683
|
+
it('maps a decodingFailed status back to the requester', async () => {
|
|
684
|
+
const store = createInMemoryStatementStore();
|
|
685
|
+
const host = makeHost(store);
|
|
686
|
+
const peer = makeMobile(store);
|
|
687
|
+
peer.subscribe(RemoteMsg, () => undefined);
|
|
688
|
+
host.respondToRequests(RemoteMsg, () => okAsync('decodingFailed'));
|
|
689
|
+
await settle();
|
|
690
|
+
const ack = await peer.request(RemoteMsg, requestMsg('p1'));
|
|
691
|
+
expect(ack.isErr()).toBe(true);
|
|
692
|
+
expect(ack._unsafeUnwrapErr()).toBeInstanceOf(DecodingError);
|
|
693
|
+
host.dispose();
|
|
694
|
+
peer.dispose();
|
|
695
|
+
});
|
|
696
|
+
it('answers with unknown when the handler errors', async () => {
|
|
697
|
+
const store = createInMemoryStatementStore();
|
|
698
|
+
const host = makeHost(store);
|
|
699
|
+
const peer = makeMobile(store);
|
|
700
|
+
peer.subscribe(RemoteMsg, () => undefined);
|
|
701
|
+
host.respondToRequests(RemoteMsg, () => errAsync(new Error('handler boom')));
|
|
702
|
+
await settle();
|
|
703
|
+
const ack = await peer.request(RemoteMsg, requestMsg('p1'));
|
|
704
|
+
expect(ack.isErr()).toBe(true);
|
|
705
|
+
expect(ack._unsafeUnwrapErr()).toBeInstanceOf(UnknownError);
|
|
706
|
+
host.dispose();
|
|
707
|
+
peer.dispose();
|
|
708
|
+
});
|
|
709
|
+
it('invokes the handler once per request and never for peer responses', async () => {
|
|
710
|
+
const store = createInMemoryStatementStore();
|
|
711
|
+
const host = makeHost(store);
|
|
712
|
+
const peer = makeMobile(store);
|
|
713
|
+
peer.subscribe(RemoteMsg, () => undefined);
|
|
714
|
+
const handler = vi.fn(() => 'success');
|
|
715
|
+
host.respondToRequests(RemoteMsg, handler);
|
|
716
|
+
await settle();
|
|
717
|
+
await peer.request(RemoteMsg, requestMsg('p1'));
|
|
718
|
+
// The host's own ACK (echoed back on its incoming topic) must not re-trigger the handler.
|
|
719
|
+
expect(handler).toHaveBeenCalledTimes(1);
|
|
720
|
+
host.dispose();
|
|
721
|
+
peer.dispose();
|
|
722
|
+
});
|
|
723
|
+
it('can answer an earlier incoming request after a newer one arrives', async () => {
|
|
724
|
+
const reqA = makeStatement({ tag: 'request', value: { requestId: 'A', data: [] } });
|
|
725
|
+
const { subscribeStatements, callbacks } = capturingSubscribe();
|
|
726
|
+
const { session } = makeSession({ peer: [reqA], subscribeStatements });
|
|
520
727
|
await delay();
|
|
521
|
-
|
|
522
|
-
|
|
728
|
+
session.subscribe(rawCodec, vi.fn()); // activate the store subscription
|
|
729
|
+
const reqB = makeStatement({ tag: 'request', value: { requestId: 'B', data: [] } });
|
|
730
|
+
callbacks[0]({ statements: [reqB], isComplete: true });
|
|
731
|
+
await delay();
|
|
732
|
+
const resA = await session.submitResponseMessage('A', 'success');
|
|
733
|
+
const resB = await session.submitResponseMessage('B', 'success');
|
|
734
|
+
expect(resA.isOk()).toBe(true);
|
|
735
|
+
expect(resB.isOk()).toBe(true);
|
|
523
736
|
});
|
|
524
|
-
it('
|
|
525
|
-
|
|
526
|
-
const
|
|
527
|
-
subscribeCallback = cb;
|
|
528
|
-
return vi.fn();
|
|
529
|
-
});
|
|
530
|
-
const { session, adapter } = makeSession({ subscribeStatements });
|
|
737
|
+
it('remains answerable after response submission retries are exhausted', async () => {
|
|
738
|
+
const peerRequest = makeStatement({ tag: 'request', value: { requestId: 'rid', data: [] } });
|
|
739
|
+
const { session, adapter } = makeSession({ peer: [peerRequest] });
|
|
531
740
|
await delay();
|
|
532
|
-
|
|
533
|
-
const
|
|
534
|
-
|
|
741
|
+
adapter.submitStatement.mockReturnValue(errAsync(new Error('store rejected')));
|
|
742
|
+
const first = await session.submitResponseMessage('rid', 'success'); // all retries fail → err + rollback
|
|
743
|
+
expect(first.isErr()).toBe(true);
|
|
744
|
+
adapter.submitStatement.mockReturnValue(okAsync(undefined)); // store recovers
|
|
745
|
+
const submitsBefore = adapter.submitStatement.mock.calls.length;
|
|
746
|
+
const second = await session.submitResponseMessage('rid', 'success'); // retryable → submits and succeeds
|
|
747
|
+
expect(second.isOk()).toBe(true);
|
|
748
|
+
expect(adapter.submitStatement.mock.calls.length).toBeGreaterThan(submitsBefore);
|
|
749
|
+
}, 3000);
|
|
750
|
+
it('absorbs a superseded response rejected as ExpiryTooLow and keeps the request answered', async () => {
|
|
751
|
+
// Two incoming requests are answered on the SHARED response channel. The response to A is in
|
|
752
|
+
// flight when the response to B takes over the channel; A then lands at a now-lower expiry and
|
|
753
|
+
// the store rejects it (ExpiryTooLow). That supersession is expected: B's response owns the
|
|
754
|
+
// channel, so A's rejection must be absorbed (not surfaced) and A must stay marked answered —
|
|
755
|
+
// re-answering would only clobber B. (Returning ok here is also what stops respondToRequests
|
|
756
|
+
// from logging it as a failed response.)
|
|
757
|
+
const reqA = makeStatement({ tag: 'request', value: { requestId: 'A', data: [] } });
|
|
758
|
+
const { subscribeStatements, callbacks } = capturingSubscribe();
|
|
759
|
+
const { submitStatement, pendings } = deferredSubmit();
|
|
760
|
+
const { session, adapter } = makeSession({ peer: [reqA], subscribeStatements, submitStatement });
|
|
535
761
|
await delay();
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
const decoded = StatementData.dec(lastStatement.data);
|
|
540
|
-
const respondingId = decoded.tag === 'request' ? decoded.value.requestId : '';
|
|
541
|
-
subscribeCallback({
|
|
542
|
-
statements: [makeStatement({ tag: 'response', value: { requestId: respondingId, responseCode: 'success' } })],
|
|
543
|
-
isComplete: true,
|
|
544
|
-
});
|
|
762
|
+
session.subscribe(rawCodec, vi.fn()); // activate the store subscription
|
|
763
|
+
const reqB = makeStatement({ tag: 'request', value: { requestId: 'B', data: [] } });
|
|
764
|
+
callbacks[0]({ statements: [reqB], isComplete: true });
|
|
545
765
|
await delay();
|
|
546
|
-
const
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
766
|
+
const resAPromise = session.submitResponseMessage('A', 'success'); // in flight on the shared channel
|
|
767
|
+
const resBPromise = session.submitResponseMessage('B', 'success'); // supersedes A
|
|
768
|
+
await delay(); // both reach submitStatement
|
|
769
|
+
expect(pendings).toHaveLength(2);
|
|
770
|
+
pendings.find(p => p.requestId === 'B').settle(ok(undefined)); // B lands, owns the channel
|
|
771
|
+
pendings.find(p => p.requestId === 'A').settle(err(new ExpiryTooLowError(0n, 0n))); // A lands late, rejected
|
|
772
|
+
const resA = await resAPromise;
|
|
773
|
+
const resB = await resBPromise;
|
|
774
|
+
expect(resB.isOk()).toBe(true);
|
|
775
|
+
expect(resA.isOk()).toBe(true); // superseded rejection absorbed, not surfaced as an error
|
|
776
|
+
// A stays answered: re-answering it must NOT submit again (which would clobber B's response).
|
|
777
|
+
const submitsBefore = adapter.submitStatement.mock.calls.length;
|
|
778
|
+
const reAnswer = await session.submitResponseMessage('A', 'success');
|
|
779
|
+
await delay();
|
|
780
|
+
expect(reAnswer.isOk()).toBe(true);
|
|
781
|
+
expect(adapter.submitStatement.mock.calls.length).toBe(submitsBefore); // deduped → no resubmit
|
|
782
|
+
session.dispose();
|
|
783
|
+
}, 3000);
|
|
550
784
|
});
|
|
551
|
-
|
|
785
|
+
// clearOutgoingStatement aborts the in-flight request: it drops local state, rejects waiters, and
|
|
786
|
+
// supersedes the on-chain request with an empty batch at a strictly higher expiry.
|
|
787
|
+
describe('aborting the outgoing request', () => {
|
|
552
788
|
it('is a no-op when there is no outgoing request', async () => {
|
|
553
789
|
const { session, adapter } = makeSession();
|
|
554
790
|
await delay();
|
|
@@ -557,10 +793,25 @@ describe('session', () => {
|
|
|
557
793
|
expect(result.isOk()).toBe(true);
|
|
558
794
|
expect(adapter.submitStatement.mock.calls.length).toBe(before);
|
|
559
795
|
});
|
|
560
|
-
it('
|
|
796
|
+
it('absorbs an ExpiryTooLow on the superseding empty batch as success', async () => {
|
|
797
|
+
// clearOutgoingStatement runs a single direct submit (no submitWithRetry). If the empty batch
|
|
798
|
+
// is rejected ExpiryTooLow, the channel already advanced past us — the request is already gone
|
|
799
|
+
// — so the clear has effectively happened. The caller must see success, not the sync artifact.
|
|
800
|
+
let calls = 0;
|
|
801
|
+
const submitStatement = vi.fn((stmt) => ++calls === 1
|
|
802
|
+
? okAsync(undefined) // the request itself lands
|
|
803
|
+
: errAsync(new ExpiryTooLowError(stmt.expiry ?? 0n, (0xffffffffn << 32n) | 9000000000n)));
|
|
804
|
+
const { session } = makeSession({ submitStatement });
|
|
805
|
+
await delay();
|
|
806
|
+
void session.submitRequestMessage(rawCodec, new Uint8Array([1]));
|
|
807
|
+
await delay();
|
|
808
|
+
const cleared = await session.clearOutgoingStatement();
|
|
809
|
+
expect(cleared.isOk()).toBe(true); // ExpiryTooLow suppressed
|
|
810
|
+
session.dispose();
|
|
811
|
+
}, 3000);
|
|
812
|
+
it('submits an empty batch on the same channel at a higher expiry and clears local state', async () => {
|
|
561
813
|
const { session, adapter } = makeSession();
|
|
562
814
|
await delay();
|
|
563
|
-
const rawCodec = Bytes();
|
|
564
815
|
void session.submitRequestMessage(rawCodec, new Uint8Array([1, 2, 3]));
|
|
565
816
|
await delay();
|
|
566
817
|
const liveCall = adapter.submitStatement.mock.calls.at(-1)?.[0];
|
|
@@ -577,7 +828,7 @@ describe('session', () => {
|
|
|
577
828
|
expect(clearDecoded.value.data).toEqual([]);
|
|
578
829
|
expect(clearCall.channel).toBe(liveCall.channel);
|
|
579
830
|
expect(clearCall.expiry).toBeGreaterThanOrEqual(liveCall.expiry);
|
|
580
|
-
//
|
|
831
|
+
// State is cleared: the next message starts a brand-new batch (data length 1, not 2).
|
|
581
832
|
void session.submitRequestMessage(rawCodec, new Uint8Array([4]));
|
|
582
833
|
await delay();
|
|
583
834
|
const afterClear = adapter.submitStatement.mock.calls.at(-1)?.[0];
|
|
@@ -585,10 +836,29 @@ describe('session', () => {
|
|
|
585
836
|
if (afterDecoded.tag === 'request')
|
|
586
837
|
expect(afterDecoded.value.data.length).toBe(1);
|
|
587
838
|
});
|
|
839
|
+
it('evicts the live on-chain request by superseding it at a strictly higher expiry', async () => {
|
|
840
|
+
// The store rejects an equal-expiry write on the same channel, so the empty batch must go out
|
|
841
|
+
// at a strictly higher expiry to actually evict the live request.
|
|
842
|
+
const store = createInMemoryStatementStore();
|
|
843
|
+
const session = makeHost(store);
|
|
844
|
+
await settle();
|
|
845
|
+
void session.submitRequestMessage(rawCodec, new Uint8Array([1, 2, 3]));
|
|
846
|
+
await settle();
|
|
847
|
+
const result = await session.clearOutgoingStatement();
|
|
848
|
+
expect(result.isOk()).toBe(true);
|
|
849
|
+
await settle();
|
|
850
|
+
const requests = store
|
|
851
|
+
.currentStatements()
|
|
852
|
+
.map(s => StatementData.dec(s.data))
|
|
853
|
+
.filter(d => d.tag === 'request');
|
|
854
|
+
expect(requests.some(d => d.tag === 'request' && d.value.data.length > 0)).toBe(false);
|
|
855
|
+
expect(requests.some(d => d.tag === 'request' && d.value.data.length === 0)).toBe(true);
|
|
856
|
+
session.dispose();
|
|
857
|
+
});
|
|
588
858
|
it('rejects the pending response waiter so callers unwind', async () => {
|
|
589
859
|
const { session } = makeSession();
|
|
590
860
|
await delay();
|
|
591
|
-
const submit = await session.submitRequestMessage(
|
|
861
|
+
const submit = await session.submitRequestMessage(rawCodec, new Uint8Array([9]));
|
|
592
862
|
expect(submit.isOk()).toBe(true);
|
|
593
863
|
const requestId = submit._unsafeUnwrap().requestId;
|
|
594
864
|
const waiter = session.waitForResponseMessage(requestId);
|
|
@@ -596,10 +866,9 @@ describe('session', () => {
|
|
|
596
866
|
const waited = await waiter;
|
|
597
867
|
expect(waited.isErr()).toBe(true);
|
|
598
868
|
});
|
|
599
|
-
it('clears local state and rejects waiters even when the
|
|
869
|
+
it('clears local state and rejects waiters even when the supersede submission fails', async () => {
|
|
600
870
|
const { session, adapter } = makeSession();
|
|
601
871
|
await delay();
|
|
602
|
-
const rawCodec = Bytes();
|
|
603
872
|
const submit = await session.submitRequestMessage(rawCodec, new Uint8Array([1, 2, 3]));
|
|
604
873
|
const requestId = submit._unsafeUnwrap().requestId;
|
|
605
874
|
const waiter = session.waitForResponseMessage(requestId);
|
|
@@ -609,7 +878,7 @@ describe('session', () => {
|
|
|
609
878
|
// The pending waiter is rejected despite the failed submission.
|
|
610
879
|
const waited = await waiter;
|
|
611
880
|
expect(waited.isErr()).toBe(true);
|
|
612
|
-
//
|
|
881
|
+
// State is cleared: the next message starts a brand-new batch (data length 1, not 2).
|
|
613
882
|
adapter.submitStatement.mockReturnValue(okAsync(undefined));
|
|
614
883
|
void session.submitRequestMessage(rawCodec, new Uint8Array([4]));
|
|
615
884
|
await delay();
|
|
@@ -618,14 +887,14 @@ describe('session', () => {
|
|
|
618
887
|
if (afterDecoded.tag === 'request')
|
|
619
888
|
expect(afterDecoded.value.data.length).toBe(1);
|
|
620
889
|
});
|
|
621
|
-
it('cancels messages queued before the batch
|
|
622
|
-
// queryStatements never resolves, so init() stays pending and the message
|
|
623
|
-
//
|
|
890
|
+
it('cancels messages queued before the batch was submitted (init still pending)', async () => {
|
|
891
|
+
// queryStatements never resolves, so init() stays pending and the message sits in the queue
|
|
892
|
+
// with outgoingRequest still null.
|
|
624
893
|
const neverResolves = vi
|
|
625
894
|
.fn()
|
|
626
895
|
.mockReturnValue(new ResultAsync(new Promise(() => undefined)));
|
|
627
896
|
const { session, adapter } = makeSession({ queryStatements: neverResolves });
|
|
628
|
-
const submit = await session.submitRequestMessage(
|
|
897
|
+
const submit = await session.submitRequestMessage(rawCodec, new Uint8Array([7]));
|
|
629
898
|
const requestId = submit._unsafeUnwrap().requestId;
|
|
630
899
|
const waiter = session.waitForResponseMessage(requestId);
|
|
631
900
|
const submitsBefore = adapter.submitStatement.mock.calls.length;
|
|
@@ -637,5 +906,333 @@ describe('session', () => {
|
|
|
637
906
|
// No empty batch is submitted since there was no live on-chain request yet.
|
|
638
907
|
expect(adapter.submitStatement.mock.calls.length).toBe(submitsBefore);
|
|
639
908
|
});
|
|
909
|
+
it('does not resurrect the aborted request when a submit retry is pending', async () => {
|
|
910
|
+
let calls = 0;
|
|
911
|
+
const submitStatement = vi.fn(() => {
|
|
912
|
+
calls++;
|
|
913
|
+
return calls === 1 ? errAsync(new Error('transient')) : okAsync(undefined); // first request submit fails → retry scheduled
|
|
914
|
+
});
|
|
915
|
+
const { session, adapter } = makeSession({ submitStatement });
|
|
916
|
+
await delay();
|
|
917
|
+
void session.submitRequestMessage(rawCodec, new Uint8Array([1])); // submit #1 fails, schedules a retry
|
|
918
|
+
await session.clearOutgoingStatement(); // abort: drops outgoing state + empty supersede (submit #2)
|
|
919
|
+
const callsAfterAbort = adapter.submitStatement.mock.calls.length;
|
|
920
|
+
await new Promise(resolve => setTimeout(resolve, 100)); // let the (now-stale) retry window elapse
|
|
921
|
+
expect(adapter.submitStatement.mock.calls.length).toBe(callsAfterAbort); // retry must NOT re-send the request
|
|
922
|
+
session.dispose();
|
|
923
|
+
}, 3000);
|
|
924
|
+
});
|
|
925
|
+
// The spec mandates retrying queries (init) and submit_statement on connection failure. The
|
|
926
|
+
// session retries transient failures with a bounded backoff. (Spec §5.)
|
|
927
|
+
describe('resilience (transient-failure retries)', () => {
|
|
928
|
+
it('retries initialization after a transient query failure', async () => {
|
|
929
|
+
let attempts = 0;
|
|
930
|
+
const queryStatements = vi.fn(() => {
|
|
931
|
+
attempts++;
|
|
932
|
+
return attempts <= 2 ? errAsync(new Error('transient')) : okAsync([]); // first init attempt fails
|
|
933
|
+
});
|
|
934
|
+
const { session, adapter } = makeSession({ queryStatements });
|
|
935
|
+
void session.submitRequestMessage(rawCodec, new Uint8Array([1])); // queued during init
|
|
936
|
+
await new Promise(resolve => setTimeout(resolve, 200)); // allow re-init + activation
|
|
937
|
+
expect(adapter.submitStatement).toHaveBeenCalled(); // queued message sent after successful re-init
|
|
938
|
+
session.dispose();
|
|
939
|
+
}, 3000);
|
|
940
|
+
it('retries a request submission that transiently fails', async () => {
|
|
941
|
+
let calls = 0;
|
|
942
|
+
const submitStatement = vi.fn(() => {
|
|
943
|
+
calls++;
|
|
944
|
+
return calls === 1 ? errAsync(new Error('transient')) : okAsync(undefined);
|
|
945
|
+
});
|
|
946
|
+
const { session, adapter } = makeSession({ submitStatement });
|
|
947
|
+
await delay();
|
|
948
|
+
void session.submitRequestMessage(rawCodec, new Uint8Array([1]));
|
|
949
|
+
await new Promise(resolve => setTimeout(resolve, 150)); // allow retry
|
|
950
|
+
expect(adapter.submitStatement.mock.calls.length).toBeGreaterThanOrEqual(2); // 1 failure + ≥1 retry
|
|
951
|
+
session.dispose();
|
|
952
|
+
}, 3000);
|
|
953
|
+
it('resyncs its expiry above the chain minimum after an ExpiryTooLow rejection', async () => {
|
|
954
|
+
// The in-memory expiry counter has drifted behind the channel's real priority (prior run /
|
|
955
|
+
// other writer / propagation lag). The chain reports the minimum; the retry must clear it.
|
|
956
|
+
const CHAIN_MIN = (0xffffffffn << 32n) | 4000000000n; // well above the wall-clock priority
|
|
957
|
+
let calls = 0;
|
|
958
|
+
const submitStatement = vi.fn((stmt) => {
|
|
959
|
+
calls++;
|
|
960
|
+
return calls === 1 ? errAsync(new ExpiryTooLowError(stmt.expiry ?? 0n, CHAIN_MIN)) : okAsync(undefined);
|
|
961
|
+
});
|
|
962
|
+
const { session, adapter } = makeSession({ submitStatement });
|
|
963
|
+
await delay();
|
|
964
|
+
void session.submitRequestMessage(rawCodec, new Uint8Array([1]));
|
|
965
|
+
await new Promise(resolve => setTimeout(resolve, 100)); // allow the retry (25ms backoff)
|
|
966
|
+
expect(adapter.submitStatement.mock.calls.length).toBeGreaterThanOrEqual(2);
|
|
967
|
+
const retried = adapter.submitStatement.mock.calls.at(-1)?.[0];
|
|
968
|
+
expect(retried.expiry ?? 0n).toBeGreaterThan(CHAIN_MIN); // healed past the chain minimum
|
|
969
|
+
session.dispose();
|
|
970
|
+
}, 3000);
|
|
971
|
+
it('keeps retrying a live ExpiryTooLow past the transient-retry cap until it lands', async () => {
|
|
972
|
+
// ExpiryTooLow is a sync artifact, not a chain failure: while the submission is still live the
|
|
973
|
+
// session keeps retrying (resyncing each time) BEYOND MAX_SUBMIT_RETRIES until it lands, and
|
|
974
|
+
// never surfaces ExpiryTooLow to the caller. (A non-ExpiryTooLow error gives up at the cap —
|
|
975
|
+
// see the test below.)
|
|
976
|
+
const CHAIN_MIN = (0xffffffffn << 32n) | 4000000000n;
|
|
977
|
+
let calls = 0;
|
|
978
|
+
const submitStatement = vi.fn((stmt) => ++calls <= 6 ? errAsync(new ExpiryTooLowError(stmt.expiry ?? 0n, CHAIN_MIN)) : okAsync(undefined));
|
|
979
|
+
const { session } = makeSession({ submitStatement });
|
|
980
|
+
await delay();
|
|
981
|
+
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined);
|
|
982
|
+
try {
|
|
983
|
+
const submit = await session.submitRequestMessage(rawCodec, new Uint8Array([1]));
|
|
984
|
+
let waiterRejected = false;
|
|
985
|
+
void session.waitForResponseMessage(submit._unsafeUnwrap().requestId).mapErr(() => (waiterRejected = true));
|
|
986
|
+
await new Promise(resolve => setTimeout(resolve, 300)); // 6 retries × 25ms backoff + slack
|
|
987
|
+
expect(calls).toBeGreaterThanOrEqual(7); // retried well past the 3-attempt cap, then landed
|
|
988
|
+
expect(errorSpy).not.toHaveBeenCalledWith('submitRequest failed:', expect.anything());
|
|
989
|
+
expect(waiterRejected).toBe(false); // ExpiryTooLow never surfaced to the caller
|
|
990
|
+
}
|
|
991
|
+
finally {
|
|
992
|
+
errorSpy.mockRestore();
|
|
993
|
+
session.dispose();
|
|
994
|
+
}
|
|
995
|
+
}, 3000);
|
|
996
|
+
it('rejects the pending waiter once request-submission retries are exhausted', async () => {
|
|
997
|
+
const { session } = makeSession({
|
|
998
|
+
submitStatement: vi.fn().mockReturnValue(errAsync(new Error('store rejected'))),
|
|
999
|
+
});
|
|
1000
|
+
await delay();
|
|
1001
|
+
const submit = await session.submitRequestMessage(rawCodec, new Uint8Array([1]));
|
|
1002
|
+
const requestId = submit._unsafeUnwrap().requestId;
|
|
1003
|
+
const waited = await session.waitForResponseMessage(requestId);
|
|
1004
|
+
expect(waited.isErr()).toBe(true);
|
|
1005
|
+
}, 2000);
|
|
1006
|
+
it('absorbs a superseded older submission rejected as ExpiryTooLow without surfacing an error', async () => {
|
|
1007
|
+
// Two messages batch onto one outgoing request: the first submission (requestId A) is in
|
|
1008
|
+
// flight when the second (requestId B, higher expiry, SAME tokens) is sent. B lands first and
|
|
1009
|
+
// sets the channel priority; A then lands at a now-lower expiry and the store rejects it with
|
|
1010
|
+
// ExpiryTooLow. A is superseded, so its rejection is expected protocol behaviour — it must not
|
|
1011
|
+
// be logged as an error and must not reject the shared waiters (B carries them).
|
|
1012
|
+
const { submitStatement, pendings } = deferredSubmit();
|
|
1013
|
+
const { session, adapter } = makeSession({ submitStatement });
|
|
1014
|
+
await delay();
|
|
1015
|
+
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined);
|
|
1016
|
+
try {
|
|
1017
|
+
const first = (await session.submitRequestMessage(rawCodec, new Uint8Array([1])))._unsafeUnwrap();
|
|
1018
|
+
void session.submitRequestMessage(rawCodec, new Uint8Array([2])); // batches onto the same outgoing request
|
|
1019
|
+
await delay(); // let both submissions reach submitStatement
|
|
1020
|
+
expect(pendings).toHaveLength(2);
|
|
1021
|
+
const liveRequestId = lastSubmittedRequestId(adapter); // the newer (B) submission
|
|
1022
|
+
const live = pendings.find(p => p.requestId === liveRequestId);
|
|
1023
|
+
const superseded = pendings.find(p => p.requestId !== liveRequestId);
|
|
1024
|
+
let firstWaiterRejected = false;
|
|
1025
|
+
void session.waitForResponseMessage(first.requestId).mapErr(() => {
|
|
1026
|
+
firstWaiterRejected = true;
|
|
1027
|
+
});
|
|
1028
|
+
live.settle(ok(undefined)); // B lands, claims the channel priority
|
|
1029
|
+
await delay();
|
|
1030
|
+
superseded.settle(err(new ExpiryTooLowError(0n, 0n))); // A lands late and is rejected
|
|
1031
|
+
await delay();
|
|
1032
|
+
expect(errorSpy).not.toHaveBeenCalledWith('submitRequest failed:', expect.anything());
|
|
1033
|
+
expect(firstWaiterRejected).toBe(false); // superseded failure must not reject the shared waiter
|
|
1034
|
+
}
|
|
1035
|
+
finally {
|
|
1036
|
+
errorSpy.mockRestore();
|
|
1037
|
+
session.dispose();
|
|
1038
|
+
}
|
|
1039
|
+
}, 3000);
|
|
1040
|
+
});
|
|
1041
|
+
describe('dispose', () => {
|
|
1042
|
+
it('rejects pending waitForRequestMessage waiters', async () => {
|
|
1043
|
+
const { session } = makeSession();
|
|
1044
|
+
await delay();
|
|
1045
|
+
const waiter = session.waitForRequestMessage(rawCodec, () => 'x');
|
|
1046
|
+
session.dispose();
|
|
1047
|
+
const result = await waiter;
|
|
1048
|
+
expect(result.isErr()).toBe(true);
|
|
1049
|
+
}, 2000);
|
|
1050
|
+
it('cancels a pending init retry (no further queries)', async () => {
|
|
1051
|
+
const queryStatements = vi.fn(() => errAsync(new Error('store down'))); // init always fails → schedules retry
|
|
1052
|
+
const { session } = makeSession({ queryStatements });
|
|
1053
|
+
await delay(); // first init attempt completes (2 queries) and schedules a retry
|
|
1054
|
+
const callsBeforeDispose = queryStatements.mock.calls.length;
|
|
1055
|
+
session.dispose();
|
|
1056
|
+
await new Promise(resolve => setTimeout(resolve, 100)); // retry window elapses
|
|
1057
|
+
expect(queryStatements.mock.calls.length).toBe(callsBeforeDispose); // disposed → no further init queries
|
|
1058
|
+
}, 3000);
|
|
1059
|
+
it('rejects submitRequestMessage after dispose instead of hanging', async () => {
|
|
1060
|
+
const { session, adapter } = makeSession();
|
|
1061
|
+
await delay();
|
|
1062
|
+
session.dispose();
|
|
1063
|
+
const result = await session.submitRequestMessage(rawCodec, new Uint8Array([1]));
|
|
1064
|
+
expect(result.isErr()).toBe(true); // surfaced immediately, not a token left pending forever
|
|
1065
|
+
expect(adapter.submitStatement).not.toHaveBeenCalled();
|
|
1066
|
+
});
|
|
1067
|
+
it('rejects submitResponseMessage after dispose', async () => {
|
|
1068
|
+
const { session } = makeSession();
|
|
1069
|
+
await delay();
|
|
1070
|
+
session.dispose();
|
|
1071
|
+
const result = await session.submitResponseMessage('any-id', 'success');
|
|
1072
|
+
expect(result.isErr()).toBe(true);
|
|
1073
|
+
});
|
|
1074
|
+
it('does not re-activate when disposed while init is in flight', async () => {
|
|
1075
|
+
// dispose() lands during init's query await; init must bail before restoring state / flipping
|
|
1076
|
+
// phase to 'active', otherwise a torn-down session looks alive and accepts new work.
|
|
1077
|
+
let resolveQueries;
|
|
1078
|
+
const gate = new Promise(resolve => (resolveQueries = resolve));
|
|
1079
|
+
const queryStatements = vi.fn(() => ResultAsync.fromSafePromise(gate));
|
|
1080
|
+
const { session, adapter } = makeSession({ queryStatements });
|
|
1081
|
+
const queued = await session.submitRequestMessage(rawCodec, new Uint8Array([1])); // queued during init
|
|
1082
|
+
expect(queued.isOk()).toBe(true);
|
|
1083
|
+
session.dispose(); // dispose mid-init
|
|
1084
|
+
resolveQueries([]); // init resumes — must bail before draining the queue / activating
|
|
1085
|
+
await settle();
|
|
1086
|
+
expect(adapter.submitStatement).not.toHaveBeenCalled(); // no resurrection-driven submit
|
|
1087
|
+
const after = await session.submitRequestMessage(rawCodec, new Uint8Array([2]));
|
|
1088
|
+
expect(after.isErr()).toBe(true); // session stays disposed
|
|
1089
|
+
}, 3000);
|
|
1090
|
+
});
|
|
1091
|
+
// The in-memory adapter replicates the store's observable contract; `fidelity` pins the double's
|
|
1092
|
+
// behaviour, then end-to-end flows run two mirrored sessions (host + mobile) over ONE shared store.
|
|
1093
|
+
describe('in-memory statement store', () => {
|
|
1094
|
+
const hex = (fill) => `0x${fill.toString(16).padStart(2, '0').repeat(32)}`;
|
|
1095
|
+
function makeSignedStatement(channel, expiry, topic, data) {
|
|
1096
|
+
return {
|
|
1097
|
+
channel: channel,
|
|
1098
|
+
expiry,
|
|
1099
|
+
topics: [topic],
|
|
1100
|
+
data,
|
|
1101
|
+
proof: { type: 'sr25519', value: { signature: `0x${'00'.repeat(64)}`, signer: `0x${'00'.repeat(32)}` } },
|
|
1102
|
+
};
|
|
1103
|
+
}
|
|
1104
|
+
describe('fidelity', () => {
|
|
1105
|
+
it('accepts a new statement and returns it from a matching query', async () => {
|
|
1106
|
+
const store = createInMemoryStatementStore();
|
|
1107
|
+
await store.submitStatement(makeSignedStatement(hex(0xaa), 10n, hex(0x01), new Uint8Array([1])));
|
|
1108
|
+
const found = (await store.queryStatements({ matchAll: [new Uint8Array(32).fill(1)] }))._unsafeUnwrap();
|
|
1109
|
+
expect(found).toHaveLength(1);
|
|
1110
|
+
expect(found[0]?.expiry).toBe(10n);
|
|
1111
|
+
});
|
|
1112
|
+
it('replaces a same-channel statement only with a strictly higher expiry', async () => {
|
|
1113
|
+
const store = createInMemoryStatementStore();
|
|
1114
|
+
await store.submitStatement(makeSignedStatement(hex(0xaa), 10n, hex(0x01), new Uint8Array([1])));
|
|
1115
|
+
const higher = await store.submitStatement(makeSignedStatement(hex(0xaa), 11n, hex(0x01), new Uint8Array([2])));
|
|
1116
|
+
expect(higher.isOk()).toBe(true);
|
|
1117
|
+
expect(store.currentStatements()).toHaveLength(1);
|
|
1118
|
+
expect(store.currentStatements()[0]?.expiry).toBe(11n);
|
|
1119
|
+
});
|
|
1120
|
+
it('rejects a same-channel statement with equal or lower expiry (ExpiryTooLowError)', async () => {
|
|
1121
|
+
const store = createInMemoryStatementStore();
|
|
1122
|
+
await store.submitStatement(makeSignedStatement(hex(0xaa), 10n, hex(0x01), new Uint8Array([1])));
|
|
1123
|
+
const equal = await store.submitStatement(makeSignedStatement(hex(0xaa), 10n, hex(0x01), new Uint8Array([9])));
|
|
1124
|
+
const lower = await store.submitStatement(makeSignedStatement(hex(0xaa), 5n, hex(0x01), new Uint8Array([9])));
|
|
1125
|
+
expect(equal.isErr()).toBe(true);
|
|
1126
|
+
expect(equal._unsafeUnwrapErr()).toBeInstanceOf(ExpiryTooLowError);
|
|
1127
|
+
expect(lower.isErr()).toBe(true);
|
|
1128
|
+
// The original statement is untouched.
|
|
1129
|
+
expect(store.currentStatements()[0]?.data).toEqual(new Uint8Array([1]));
|
|
1130
|
+
});
|
|
1131
|
+
it('treats a byte-identical resubmission as known (ok, no duplicate)', async () => {
|
|
1132
|
+
const store = createInMemoryStatementStore();
|
|
1133
|
+
const stmt = makeSignedStatement(hex(0xaa), 10n, hex(0x01), new Uint8Array([1]));
|
|
1134
|
+
await store.submitStatement(stmt);
|
|
1135
|
+
const again = await store.submitStatement(stmt);
|
|
1136
|
+
expect(again.isOk()).toBe(true);
|
|
1137
|
+
expect(store.currentStatements()).toHaveLength(1);
|
|
1138
|
+
});
|
|
1139
|
+
it('coexists statements on different channels sharing a topic', async () => {
|
|
1140
|
+
const store = createInMemoryStatementStore();
|
|
1141
|
+
await store.submitStatement(makeSignedStatement(hex(0xaa), 10n, hex(0x01), new Uint8Array([1])));
|
|
1142
|
+
await store.submitStatement(makeSignedStatement(hex(0xbb), 10n, hex(0x01), new Uint8Array([2])));
|
|
1143
|
+
const found = (await store.queryStatements({ matchAll: [new Uint8Array(32).fill(1)] }))._unsafeUnwrap();
|
|
1144
|
+
expect(found).toHaveLength(2);
|
|
1145
|
+
});
|
|
1146
|
+
it('streams only post-subscription matching statements to live subscribers', async () => {
|
|
1147
|
+
const store = createInMemoryStatementStore();
|
|
1148
|
+
await store.submitStatement(makeSignedStatement(hex(0xaa), 10n, hex(0x01), new Uint8Array([1])));
|
|
1149
|
+
const pages = [];
|
|
1150
|
+
store.subscribeStatements({ matchAll: [new Uint8Array(32).fill(1)] }, page => pages.push(page));
|
|
1151
|
+
// Pre-existing statement is NOT replayed; a new matching one is delivered.
|
|
1152
|
+
expect(pages).toHaveLength(0);
|
|
1153
|
+
await store.submitStatement(makeSignedStatement(hex(0xaa), 11n, hex(0x01), new Uint8Array([2])));
|
|
1154
|
+
expect(pages).toHaveLength(1);
|
|
1155
|
+
expect(pages[0]?.statements[0]?.expiry).toBe(11n);
|
|
1156
|
+
// A non-matching topic is not delivered.
|
|
1157
|
+
await store.submitStatement(makeSignedStatement(hex(0xcc), 10n, hex(0x02), new Uint8Array([3])));
|
|
1158
|
+
expect(pages).toHaveLength(1);
|
|
1159
|
+
});
|
|
1160
|
+
});
|
|
1161
|
+
describe('end-to-end flows (host ↔ mobile over a shared store)', () => {
|
|
1162
|
+
it('completes a request → ACK → reply → ACK round trip', async () => {
|
|
1163
|
+
const store = createInMemoryStatementStore();
|
|
1164
|
+
const host = makeHost(store);
|
|
1165
|
+
const mobile = makeMobile(store);
|
|
1166
|
+
// Mobile acknowledges every incoming request and, on seeing the host's
|
|
1167
|
+
// request, sends its application reply back as a new request.
|
|
1168
|
+
mobile.respondToRequests(RemoteMsg, () => 'success');
|
|
1169
|
+
const mobileGotRequest = mobile.waitForRequestMessage(RemoteMsg, msg => msg.kind === 'request' ? msg : undefined);
|
|
1170
|
+
// Host acknowledges the mobile's reply.
|
|
1171
|
+
host.respondToRequests(RemoteMsg, () => 'success');
|
|
1172
|
+
await settle();
|
|
1173
|
+
const hostAck = host.request(RemoteMsg, { id: 'h1', kind: 'request', respondingTo: '', body: 'sign this' });
|
|
1174
|
+
const mobileReplyAck = mobileGotRequest.andThen(req => mobile.request(RemoteMsg, { id: 'm1', kind: 'reply', respondingTo: req.id, body: 'signature' }));
|
|
1175
|
+
const hostReply = host.waitForRequestMessage(RemoteMsg, msg => msg.kind === 'reply' && msg.respondingTo === 'h1' ? msg.body : undefined);
|
|
1176
|
+
await settle();
|
|
1177
|
+
expect((await hostAck).isOk()).toBe(true); // mobile ACKed the host request
|
|
1178
|
+
expect((await mobileReplyAck).isOk()).toBe(true); // host ACKed the mobile reply
|
|
1179
|
+
expect((await hostReply)._unsafeUnwrap()).toBe('signature'); // host received the reply
|
|
1180
|
+
host.dispose();
|
|
1181
|
+
mobile.dispose();
|
|
1182
|
+
});
|
|
1183
|
+
it('answers an incoming request that went unanswered until a restart', async () => {
|
|
1184
|
+
const store = createInMemoryStatementStore();
|
|
1185
|
+
const host = makeHost(store);
|
|
1186
|
+
// Host must be listening to receive the eventual ACK.
|
|
1187
|
+
host.respondToRequests(RemoteMsg, () => 'success');
|
|
1188
|
+
// Mobile receives the request but never responds (no responder registered).
|
|
1189
|
+
let mobile = makeMobile(store);
|
|
1190
|
+
mobile.subscribe(RemoteMsg, () => undefined); // activate the store subscription, but do not ACK
|
|
1191
|
+
await settle();
|
|
1192
|
+
const hostAck = host.request(RemoteMsg, { id: 'h1', kind: 'request', respondingTo: '', body: 'sign this' });
|
|
1193
|
+
await settle();
|
|
1194
|
+
const beforeRestart = await Promise.race([
|
|
1195
|
+
Promise.resolve(hostAck).then(() => 'resolved'),
|
|
1196
|
+
new Promise(resolve => setTimeout(() => resolve('pending'), 20)),
|
|
1197
|
+
]);
|
|
1198
|
+
expect(beforeRestart).toBe('pending'); // unanswered while the responder is absent
|
|
1199
|
+
// Restart: a fresh mobile session on the same store rediscovers the
|
|
1200
|
+
// unanswered request via init() and now answers it.
|
|
1201
|
+
mobile.dispose();
|
|
1202
|
+
mobile = makeMobile(store);
|
|
1203
|
+
mobile.respondToRequests(RemoteMsg, () => 'success');
|
|
1204
|
+
await settle();
|
|
1205
|
+
expect((await hostAck).isOk()).toBe(true);
|
|
1206
|
+
host.dispose();
|
|
1207
|
+
mobile.dispose();
|
|
1208
|
+
});
|
|
1209
|
+
it('delivers the mobile reply to the host independently of the request ACK', async () => {
|
|
1210
|
+
// The application reply (waitForRequestMessage) and the transport ACK (request →
|
|
1211
|
+
// waitForResponseMessage) are independent channels. Here the mobile sends ONLY the reply
|
|
1212
|
+
// and never ACKs the host request — the host must still receive the reply while its request
|
|
1213
|
+
// ACK stays outstanding.
|
|
1214
|
+
const store = createInMemoryStatementStore();
|
|
1215
|
+
const host = makeHost(store);
|
|
1216
|
+
const mobile = makeMobile(store);
|
|
1217
|
+
const mobileGotRequest = mobile.waitForRequestMessage(RemoteMsg, msg => msg.kind === 'request' ? msg : undefined);
|
|
1218
|
+
void mobileGotRequest.andThen(() =>
|
|
1219
|
+
// Fire-and-forget the reply (do not wait for the host to ACK it).
|
|
1220
|
+
mobile.submitRequestMessage(RemoteMsg, { id: 'm1', kind: 'reply', respondingTo: 'h1', body: 'signature' }));
|
|
1221
|
+
await settle();
|
|
1222
|
+
const hostReply = host.waitForRequestMessage(RemoteMsg, msg => msg.kind === 'reply' && msg.respondingTo === 'h1' ? msg.body : undefined);
|
|
1223
|
+
const hostAck = host.request(RemoteMsg, { id: 'h1', kind: 'request', respondingTo: '', body: 'sign this' });
|
|
1224
|
+
await settle();
|
|
1225
|
+
// The reply is delivered…
|
|
1226
|
+
expect((await hostReply)._unsafeUnwrap()).toBe('signature');
|
|
1227
|
+
// …while the transport ACK is still outstanding (mobile never sent it).
|
|
1228
|
+
const ackState = await Promise.race([
|
|
1229
|
+
Promise.resolve(hostAck).then(() => 'resolved'),
|
|
1230
|
+
new Promise(resolve => setTimeout(() => resolve('pending'), 20)),
|
|
1231
|
+
]);
|
|
1232
|
+
expect(ackState).toBe('pending');
|
|
1233
|
+
host.dispose();
|
|
1234
|
+
mobile.dispose();
|
|
1235
|
+
});
|
|
1236
|
+
});
|
|
640
1237
|
});
|
|
641
1238
|
});
|