@jskit-ai/assistant-runtime 0.2.1 → 0.2.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/README.md +2 -1
- package/locales/en.json +2 -1
- package/package.json +3 -3
- package/src/client/composables/useAssistantConversation.js +7 -3
- package/src/client/support/subscribeAssistantConversation.js +18 -5
- package/test/assistantConversationBinding.browser.test.js +28 -0
- package/test/assistantConversationSubscriptions.test.js +150 -25
package/README.md
CHANGED
|
@@ -34,7 +34,8 @@ modes) and `packages/agent-docs/site/guide/app-extras/assistant-conversation.md`
|
|
|
34
34
|
With the existing realtime diagnostic option (`JSKIT_REALTIME_DEBUG=1`, or
|
|
35
35
|
`config.realtime.debug=true`), conversation subscriptions emit one `pending`
|
|
36
36
|
warning if admission/snapshot reading has not returned within the client's
|
|
37
|
-
10-second
|
|
37
|
+
10-second budget, after which the client shows a loading notice and keeps waiting
|
|
38
|
+
(it gives up only after 120 seconds). The warning names the current stage and the
|
|
38
39
|
server-generated epoch; a conversation identity is included only after its
|
|
39
40
|
successful authorized open. Observer release also records the current stage and
|
|
40
41
|
reason. While diagnostics are enabled, every stage transition is recorded at
|
package/locales/en.json
CHANGED
|
@@ -130,7 +130,8 @@
|
|
|
130
130
|
},
|
|
131
131
|
"errors": {
|
|
132
132
|
"assistant_history_inspection_required": "This conversation has ambiguous historical SQL rows. Inspect the reported rows offline before using the common runtime; no history was changed.",
|
|
133
|
-
"assistant_subscription_transport_failed": "Chat updates could not reconnect. Retrying automatically; Reload chat to try now."
|
|
133
|
+
"assistant_subscription_transport_failed": "Chat updates could not reconnect. Retrying automatically; Reload chat to try now.",
|
|
134
|
+
"assistant_subscription_waiting": "Chat is still loading. Waiting for the server; Reload chat to try now."
|
|
134
135
|
}
|
|
135
136
|
}
|
|
136
137
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jskit-ai/assistant-runtime",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.2",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"scripts": {
|
|
6
6
|
"test": "node --test --test-concurrency=1"
|
|
@@ -13,12 +13,12 @@
|
|
|
13
13
|
"./locales/en.json": "./locales/en.json"
|
|
14
14
|
},
|
|
15
15
|
"dependencies": {
|
|
16
|
-
"@jskit-ai/assistant-core": "0.2.
|
|
16
|
+
"@jskit-ai/assistant-core": "0.2.2",
|
|
17
17
|
"json-rest-schema": "^1.0.17"
|
|
18
18
|
},
|
|
19
19
|
"peerDependencies": {
|
|
20
20
|
"@jskit-ai/http-runtime": "0.2.0",
|
|
21
|
-
"@jskit-ai/http-web": "0.2.
|
|
21
|
+
"@jskit-ai/http-web": "0.2.1",
|
|
22
22
|
"@jskit-ai/kernel": "0.2.0",
|
|
23
23
|
"@jskit-ai/realtime": "0.2.0",
|
|
24
24
|
"@jskit-ai/shell-web": "0.2.0",
|
|
@@ -1009,10 +1009,15 @@ function createConversationBinding({ conversationId, endpoint = "", surfaceId =
|
|
|
1009
1009
|
} else current.draft.value = "";
|
|
1010
1010
|
}
|
|
1011
1011
|
onSubmit?.();
|
|
1012
|
+
const dispatch = () => prepare ? current.submitPrepared(captured, { messageId, onAccepted: acknowledge, prepare })
|
|
1013
|
+
: current.send(captured, { messageId, onAccepted: acknowledge });
|
|
1012
1014
|
// A restored uncertain receipt may have no retained acceptance callback.
|
|
1013
|
-
// Inspection can acknowledge it
|
|
1015
|
+
// Inspection can acknowledge it. Only the server's proof that the message
|
|
1016
|
+
// never reached the AI (which marks it failed) lets Retry send it again.
|
|
1014
1017
|
if (retry?.status === "uncertain") return current.inspectDelivery(messageId).then(receipt => {
|
|
1015
1018
|
if (!current.current.value || !isCurrent()) return false;
|
|
1019
|
+
if (receipt?.status === "not-sent" && receipt.messageId === messageId &&
|
|
1020
|
+
current.delivery.find(messageId)?.status === "failed") return dispatch();
|
|
1016
1021
|
if (receipt?.status !== "accepted") {
|
|
1017
1022
|
const error = current.error.value || t("jskit.assistant.runtime.delivery.notConfirmed");
|
|
1018
1023
|
const pending = current.delivery.find(messageId);
|
|
@@ -1022,8 +1027,7 @@ function createConversationBinding({ conversationId, endpoint = "", surfaceId =
|
|
|
1022
1027
|
acknowledge();
|
|
1023
1028
|
return { ...receipt, ok: true };
|
|
1024
1029
|
});
|
|
1025
|
-
return
|
|
1026
|
-
: current.send(captured, { messageId, onAccepted: acknowledge });
|
|
1030
|
+
return dispatch();
|
|
1027
1031
|
}
|
|
1028
1032
|
const adapter = computed(() => {
|
|
1029
1033
|
const current = runtime.value;
|
|
@@ -16,6 +16,12 @@ function patchLoadedHistoryPages(pages = [], patch) {
|
|
|
16
16
|
}
|
|
17
17
|
|
|
18
18
|
const SNAPSHOT_EVENTS = new Set(["accepted", "settled", "configuration", "phase", "error", "replaced", "goal"]);
|
|
19
|
+
// A connected server may need longer than the normal budget to answer (an
|
|
20
|
+
// overloaded host took 15 s). Abandoning it then discards the server's work and
|
|
21
|
+
// repeats it, so a slow answer only shows a loading notice; it is treated as
|
|
22
|
+
// lost, unsubscribed and retried only after the much longer cap.
|
|
23
|
+
const SUBSCRIPTION_NOTICE_MS = 10_000;
|
|
24
|
+
const SUBSCRIPTION_LOST_MS = 120_000;
|
|
19
25
|
|
|
20
26
|
function subscribeAssistantConversation({
|
|
21
27
|
socket, conversationId, targetSurfaceId, hostSurfaceId, workspaceSlug,
|
|
@@ -44,6 +50,7 @@ function subscribeAssistantConversation({
|
|
|
44
50
|
// otherwise only the next connect would recover the chat.
|
|
45
51
|
let retryTimer = null;
|
|
46
52
|
let retryDelay = 1_000;
|
|
53
|
+
let waitingTimer = null;
|
|
47
54
|
const completed = new Map();
|
|
48
55
|
const settledTurns = new Set();
|
|
49
56
|
const pendingReads = new Set();
|
|
@@ -187,12 +194,14 @@ function subscribeAssistantConversation({
|
|
|
187
194
|
if (epoch === null) pending.push(payload);
|
|
188
195
|
else deliver(payload);
|
|
189
196
|
}
|
|
190
|
-
function
|
|
197
|
+
function clearTimers() {
|
|
191
198
|
clearTimeout(retryTimer);
|
|
192
199
|
retryTimer = null;
|
|
200
|
+
clearTimeout(waitingTimer);
|
|
201
|
+
waitingTimer = null;
|
|
193
202
|
}
|
|
194
203
|
function subscribe({ preserveHistory = false } = {}) {
|
|
195
|
-
|
|
204
|
+
clearTimers();
|
|
196
205
|
if (disposed || !socket.connected) return;
|
|
197
206
|
const attempt = ++generation;
|
|
198
207
|
epoch = null;
|
|
@@ -205,8 +214,12 @@ function subscribeAssistantConversation({
|
|
|
205
214
|
settledTurns.clear();
|
|
206
215
|
reloadInFlight = null;
|
|
207
216
|
reloadQueued = false;
|
|
208
|
-
|
|
217
|
+
waitingTimer = setTimeout(() => onError(Object.assign(
|
|
218
|
+
new Error("Chat is still loading. Waiting for the server; Reload chat to try now."),
|
|
219
|
+
{ code: "assistant_subscription_waiting" })), SUBSCRIPTION_NOTICE_MS);
|
|
220
|
+
socket.timeout(SUBSCRIPTION_LOST_MS).emit(ASSISTANT_CONVERSATION_SUBSCRIBE, input, (error, response) => {
|
|
209
221
|
if (disposed || attempt !== generation) return;
|
|
222
|
+
clearTimers();
|
|
210
223
|
if (error || response?.ok !== true) {
|
|
211
224
|
listening = false;
|
|
212
225
|
pending = [];
|
|
@@ -232,7 +245,7 @@ function subscribeAssistantConversation({
|
|
|
232
245
|
});
|
|
233
246
|
}
|
|
234
247
|
function disconnected() {
|
|
235
|
-
|
|
248
|
+
clearTimers();
|
|
236
249
|
generation += 1;
|
|
237
250
|
epoch = null;
|
|
238
251
|
pending = [];
|
|
@@ -248,7 +261,7 @@ function subscribeAssistantConversation({
|
|
|
248
261
|
function release() {
|
|
249
262
|
if (disposed) return;
|
|
250
263
|
disposed = true;
|
|
251
|
-
|
|
264
|
+
clearTimers();
|
|
252
265
|
generation += 1;
|
|
253
266
|
pending = [];
|
|
254
267
|
completed.clear();
|
|
@@ -2311,6 +2311,34 @@ test("prepared binding preserves retry identity, newer composer input and inspec
|
|
|
2311
2311
|
assert.deepEqual(await f.page.evaluate(() => window.taskClearedFiles), [["original-file"]]);
|
|
2312
2312
|
});
|
|
2313
2313
|
|
|
2314
|
+
test("prepared Retry sends an uncertain message again only after the server proves it was never sent", options, async t => {
|
|
2315
|
+
const f = await fixture(t);
|
|
2316
|
+
await f.page.evaluate(() => {
|
|
2317
|
+
window.provenBinding = window.conversationFixture.acquireConversation({ conversationId: "chat:3", active: false });
|
|
2318
|
+
window.provenReader = window.conversationFixture.acquireConversation({ conversationId: "chat:3" });
|
|
2319
|
+
});
|
|
2320
|
+
await expect.poll(() => f.page.evaluate(() => window.provenReader.runtime.value.available.value)).toBe(true);
|
|
2321
|
+
await f.command("mode", { mode: "uncertain" });
|
|
2322
|
+
const uncertain = await f.page.evaluate(() => window.provenBinding.submitPrepared({ message: "Send me once." },
|
|
2323
|
+
{ messageId: "proven-retry", prepare: captured => captured.request }));
|
|
2324
|
+
assert.equal(uncertain.status, "uncertain");
|
|
2325
|
+
assert.equal((await f.sent()).length, 1);
|
|
2326
|
+
// An unknown receipt still never resends.
|
|
2327
|
+
const unknown = await f.page.evaluate(() => window.provenBinding.submitPrepared({ message: "Do not recapture." },
|
|
2328
|
+
{ retryMessageId: "proven-retry", prepare: captured => captured.request }));
|
|
2329
|
+
assert.equal(unknown.status, "uncertain");
|
|
2330
|
+
assert.equal((await f.sent()).length, 1);
|
|
2331
|
+
await f.command("mode", { mode: "not-sent" });
|
|
2332
|
+
const retried = await f.page.evaluate(() => window.provenBinding.submitPrepared({ message: "Do not recapture." },
|
|
2333
|
+
{ retryMessageId: "proven-retry", prepare: captured => captured.request }));
|
|
2334
|
+
assert.equal(retried.ok, true);
|
|
2335
|
+
const sent = await f.sent();
|
|
2336
|
+
assert.equal(sent.length, 2);
|
|
2337
|
+
assert.equal(sent[1].input.messageId, "proven-retry");
|
|
2338
|
+
assert.equal(sent[1].input.text, "Send me once.");
|
|
2339
|
+
assert.equal(await f.page.evaluate(() => window.provenBinding.runtime.value.delivery.find("proven-retry")?.status ?? "accepted"), "accepted");
|
|
2340
|
+
});
|
|
2341
|
+
|
|
2314
2342
|
test("acceptance settles a voice-retained conversation after its text view retargets without clearing the next composer", options, async t => {
|
|
2315
2343
|
const f = await fixture(t, 1280, { acceptedDraft: true });
|
|
2316
2344
|
await f.page.evaluate(() => window.conversationFixture.attachments(true));
|
|
@@ -329,45 +329,42 @@ test("slow successful subscriptions warn once with the blocked stage and no conv
|
|
|
329
329
|
assert.equal(f.fetches.length, 0);
|
|
330
330
|
});
|
|
331
331
|
|
|
332
|
-
test("
|
|
332
|
+
test("a real subscription slower than ten seconds keeps its observer, says it is still loading and delivers the late snapshot", { timeout: 25_000 }, async t => {
|
|
333
333
|
const f = await fixture(t);
|
|
334
334
|
const socket = await f.connect();
|
|
335
335
|
const socketId = socket.id;
|
|
336
336
|
const id = f.ids[0];
|
|
337
|
-
await f.conversations.get(id).send({ messageId: "survives-
|
|
337
|
+
await f.conversations.get(id).send({ messageId: "survives-wait", text: "Keep working through a slow snapshot" });
|
|
338
338
|
await until(f.changes, () => f.fetches.length === 1);
|
|
339
339
|
const barrier = f.holdNextRead(id);
|
|
340
340
|
const view = f.view(socket, id);
|
|
341
341
|
await until(f.changes, () => barrier.started);
|
|
342
|
-
const
|
|
343
|
-
while (!view.errors.length
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
assert.equal(
|
|
347
|
-
assert.equal(view.errors[0].cause.message, "operation has timed out");
|
|
342
|
+
const subscriptionEpoch = f.subscriptionTimings.at(-1).subscriptionEpoch;
|
|
343
|
+
while (!view.errors.length) await once(f.changes, "change", { signal: AbortSignal.timeout(12_000) });
|
|
344
|
+
assert.equal(view.errors[0].message, "Chat is still loading. Waiting for the server; Reload chat to try now.");
|
|
345
|
+
assert.equal(view.errors[0].code, "assistant_subscription_waiting");
|
|
346
|
+
assert.equal(f.active(), 1, "The slow observer stays attached while its snapshot is read");
|
|
348
347
|
assert.equal(socket.id, socketId);
|
|
349
348
|
assert.equal(socket.connected, true);
|
|
350
349
|
assert.equal(view.states.length, 0);
|
|
351
|
-
assert.equal(f.fetches[0].signal.aborted, false);
|
|
352
|
-
view.dispose.reload();
|
|
353
|
-
await until(f.changes, () => view.states.length === 1 && f.active() === 1);
|
|
354
|
-
assert.equal(view.states[0].conversationLog[0].user.messageId, "survives-timeout");
|
|
355
|
-
const statesBeforeLateRead = view.states.length;
|
|
356
350
|
barrier.resolve();
|
|
357
|
-
await until(f.changes, () =>
|
|
358
|
-
|
|
359
|
-
const
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
assert.equal(
|
|
363
|
-
assert.
|
|
364
|
-
assert.
|
|
365
|
-
assert.equal(f.
|
|
351
|
+
await until(f.changes, () => view.states.length === 1);
|
|
352
|
+
assert.equal(view.states[0].conversationLog[0].user.messageId, "survives-wait");
|
|
353
|
+
const records = f.subscriptionTimings.filter(record => record.subscriptionEpoch === subscriptionEpoch);
|
|
354
|
+
assert.equal(records.some(record => record.stage === "abandoned"), false, "Slow work is never abandoned");
|
|
355
|
+
const acknowledged = records.find(record => record.stage === "acknowledgement");
|
|
356
|
+
assert.equal(acknowledged.level, "warn", "A slow snapshot remains visible at the production warning level");
|
|
357
|
+
assert.equal(acknowledged.previousStage, "snapshot-read");
|
|
358
|
+
assert.ok(acknowledged.elapsedMs >= 10_000);
|
|
359
|
+
assert.equal(f.subscriptionTimings.filter(record => record.stage === "authentication").length, 1,
|
|
360
|
+
"Waiting never sends another subscription");
|
|
361
|
+
assert.equal(view.errors.length, 1);
|
|
362
|
+
assert.equal(f.active(), 1);
|
|
366
363
|
assert.equal(f.fetches.length, 1);
|
|
367
364
|
assert.equal(f.fetches[0].signal.aborted, false);
|
|
368
365
|
assert.equal(socket.listeners(ASSISTANT_CONVERSATION_EVENT).length, 1);
|
|
369
|
-
f.fetches[0].text("Still running after the
|
|
370
|
-
await until(f.changes, () => view.received.some(event => event.text?.includes("
|
|
366
|
+
f.fetches[0].text("Still running after the slow snapshot.");
|
|
367
|
+
await until(f.changes, () => view.received.some(event => event.text?.includes("slow snapshot")));
|
|
371
368
|
view.dispose();
|
|
372
369
|
await until(f.changes, () => f.active() === 0);
|
|
373
370
|
});
|
|
@@ -474,7 +471,7 @@ for (const failure of ["rejection", "timeout"]) {
|
|
|
474
471
|
const requests = [], states = [], errors = [];
|
|
475
472
|
let reads = 0;
|
|
476
473
|
socket.timeout = timeout => ({ emit(name, input, acknowledge) {
|
|
477
|
-
assert.equal(timeout,
|
|
474
|
+
assert.equal(timeout, 120_000, "Only a much longer silence counts as a lost answer");
|
|
478
475
|
assert.equal(name, ASSISTANT_CONVERSATION_SUBSCRIBE);
|
|
479
476
|
requests.push({ input, acknowledge });
|
|
480
477
|
} });
|
|
@@ -1076,6 +1073,134 @@ test("a retried resubscribe keeps the loaded history its caller preserved", t =>
|
|
|
1076
1073
|
subscription();
|
|
1077
1074
|
});
|
|
1078
1075
|
|
|
1076
|
+
// Socket.IO's acknowledgement deadline and disconnect handling, on mocked timers.
|
|
1077
|
+
function slowServerSocket(t) {
|
|
1078
|
+
t.mock.timers.enable({ apis: ["setTimeout"] });
|
|
1079
|
+
const socket = new EventEmitter();
|
|
1080
|
+
socket.connected = true;
|
|
1081
|
+
const subscribes = [], unsubscribes = [];
|
|
1082
|
+
socket.on(ASSISTANT_CONVERSATION_UNSUBSCRIBE, input => unsubscribes.push(input));
|
|
1083
|
+
socket.timeout = milliseconds => ({ emit(name, input, acknowledge) {
|
|
1084
|
+
let settled = false;
|
|
1085
|
+
const settle = (...args) => {
|
|
1086
|
+
if (settled) return;
|
|
1087
|
+
settled = true;
|
|
1088
|
+
clearTimeout(timer);
|
|
1089
|
+
acknowledge(...args);
|
|
1090
|
+
};
|
|
1091
|
+
const timer = setTimeout(() => settle(new Error("operation has timed out")), milliseconds);
|
|
1092
|
+
subscribes.push({ name, input, answer: response => settle(null, response),
|
|
1093
|
+
drop: () => settle(new Error("socket has been disconnected")) });
|
|
1094
|
+
} });
|
|
1095
|
+
function disconnect() {
|
|
1096
|
+
socket.connected = false;
|
|
1097
|
+
socket.emit("disconnect");
|
|
1098
|
+
for (const request of subscribes) request.drop();
|
|
1099
|
+
}
|
|
1100
|
+
return { socket, subscribes, unsubscribes, disconnect };
|
|
1101
|
+
}
|
|
1102
|
+
const slowState = { id: "chat", conversationLog: [], streaming: { revision: 0, messages: [] } };
|
|
1103
|
+
function slowView(socket) {
|
|
1104
|
+
const states = [], errors = [];
|
|
1105
|
+
const subscription = subscribeAssistantConversation({ socket, conversationId: "chat", targetSurfaceId: "home", hostSurfaceId: "home",
|
|
1106
|
+
read: () => slowState, onState: state => states.push(state), onError: error => errors.push(error) });
|
|
1107
|
+
return { states, errors, subscription };
|
|
1108
|
+
}
|
|
1109
|
+
|
|
1110
|
+
test("a connected subscription keeps waiting for a slow server instead of abandoning its work at ten seconds", t => {
|
|
1111
|
+
// Production: an overloaded daemon answered after 14-15 s; the client gave
|
|
1112
|
+
// up at 10 s, unsubscribed and retried with 10 s again, so the chat never loaded.
|
|
1113
|
+
const { socket, subscribes, unsubscribes } = slowServerSocket(t);
|
|
1114
|
+
const { states, errors, subscription } = slowView(socket);
|
|
1115
|
+
t.mock.timers.tick(9_999);
|
|
1116
|
+
assert.equal(errors.length, 0);
|
|
1117
|
+
t.mock.timers.tick(1);
|
|
1118
|
+
assert.equal(errors.length, 1, "Past the normal budget the user is told the chat is still loading");
|
|
1119
|
+
assert.equal(errors[0].code, "assistant_subscription_waiting");
|
|
1120
|
+
assert.equal(errors[0].message, "Chat is still loading. Waiting for the server; Reload chat to try now.");
|
|
1121
|
+
t.mock.timers.tick(5_000);
|
|
1122
|
+
subscribes[0].answer({ ok: true, streamEpoch: "slow", state: slowState });
|
|
1123
|
+
assert.equal(states.length, 1, "The fifteen-second answer is delivered");
|
|
1124
|
+
assert.equal(subscribes.length, 1, "Waiting never starts another subscription");
|
|
1125
|
+
assert.deepEqual(unsubscribes, [], "Waiting never abandons the server's work");
|
|
1126
|
+
t.mock.timers.tick(300_000);
|
|
1127
|
+
assert.equal(subscribes.length, 1);
|
|
1128
|
+
assert.equal(errors.length, 1);
|
|
1129
|
+
subscription();
|
|
1130
|
+
assert.equal(unsubscribes.length, 1, "Disposal still releases the observer");
|
|
1131
|
+
});
|
|
1132
|
+
|
|
1133
|
+
test("a subscription answer lost past the two-minute cap still unsubscribes and retries with backoff", t => {
|
|
1134
|
+
const { socket, subscribes, unsubscribes } = slowServerSocket(t);
|
|
1135
|
+
const { states, errors, subscription } = slowView(socket);
|
|
1136
|
+
t.mock.timers.tick(119_999);
|
|
1137
|
+
assert.deepEqual(errors.map(error => error.code), ["assistant_subscription_waiting"]);
|
|
1138
|
+
assert.deepEqual(unsubscribes, []);
|
|
1139
|
+
t.mock.timers.tick(1);
|
|
1140
|
+
assert.deepEqual(unsubscribes, [{ subscriptionId: subscribes[0].input.subscriptionId }]);
|
|
1141
|
+
assert.equal(errors[1].code, "assistant_subscription_transport_failed");
|
|
1142
|
+
assert.equal(errors[1].cause.message, "operation has timed out");
|
|
1143
|
+
t.mock.timers.tick(999);
|
|
1144
|
+
assert.equal(subscribes.length, 1);
|
|
1145
|
+
t.mock.timers.tick(1);
|
|
1146
|
+
assert.equal(subscribes.length, 2, "The lost answer retries after the existing backoff");
|
|
1147
|
+
assert.deepEqual(subscribes[1].input, subscribes[0].input);
|
|
1148
|
+
t.mock.timers.tick(10_000);
|
|
1149
|
+
assert.equal(errors[2].code, "assistant_subscription_waiting", "Each slow attempt says it is still loading");
|
|
1150
|
+
subscribes[1].answer({ ok: true, streamEpoch: "recovered", state: slowState });
|
|
1151
|
+
assert.equal(states.length, 1);
|
|
1152
|
+
assert.equal(unsubscribes.length, 1);
|
|
1153
|
+
subscription();
|
|
1154
|
+
});
|
|
1155
|
+
|
|
1156
|
+
test("a disconnect while a slow subscription waits resets it and the next connect subscribes again", t => {
|
|
1157
|
+
const { socket, subscribes, unsubscribes, disconnect } = slowServerSocket(t);
|
|
1158
|
+
const { states, errors, subscription } = slowView(socket);
|
|
1159
|
+
t.mock.timers.tick(10_000);
|
|
1160
|
+
assert.equal(errors.length, 1);
|
|
1161
|
+
disconnect();
|
|
1162
|
+
t.mock.timers.tick(300_000);
|
|
1163
|
+
assert.equal(subscribes.length, 1, "A disconnected socket waits for its own reconnect");
|
|
1164
|
+
assert.deepEqual(unsubscribes, [], "There is no connection to unsubscribe on");
|
|
1165
|
+
assert.equal(errors.length, 1, "A disconnect reports no lost answer and no further notice");
|
|
1166
|
+
socket.connected = true;
|
|
1167
|
+
socket.emit("connect");
|
|
1168
|
+
assert.equal(subscribes.length, 2);
|
|
1169
|
+
assert.deepEqual(subscribes[1].input, subscribes[0].input);
|
|
1170
|
+
subscribes[1].answer({ ok: true, streamEpoch: "reconnected", state: slowState });
|
|
1171
|
+
assert.equal(states.length, 1);
|
|
1172
|
+
t.mock.timers.tick(300_000);
|
|
1173
|
+
assert.equal(errors.length, 1);
|
|
1174
|
+
subscription();
|
|
1175
|
+
});
|
|
1176
|
+
|
|
1177
|
+
test("Reload chat while a slow subscription waits reads history or resubscribes, ignoring the earlier late answer", async t => {
|
|
1178
|
+
const { socket, subscribes, unsubscribes } = slowServerSocket(t);
|
|
1179
|
+
const { states, errors, subscription } = slowView(socket);
|
|
1180
|
+
t.mock.timers.tick(10_000);
|
|
1181
|
+
assert.equal(errors.length, 1);
|
|
1182
|
+
await subscription.reload();
|
|
1183
|
+
assert.equal(states.length, 1, "Reload reads canonical history while the subscription keeps waiting");
|
|
1184
|
+
assert.equal(subscribes.length, 1);
|
|
1185
|
+
subscription.reload({ resubscribe: true });
|
|
1186
|
+
assert.equal(subscribes.length, 2, "A manual resubscribe replaces the waiting attempt by its identity");
|
|
1187
|
+
assert.deepEqual(subscribes[1].input, subscribes[0].input);
|
|
1188
|
+
assert.deepEqual(unsubscribes, []);
|
|
1189
|
+
t.mock.timers.tick(5_000);
|
|
1190
|
+
subscribes[0].answer({ ok: true, streamEpoch: "replaced", state: slowState });
|
|
1191
|
+
assert.equal(states.length, 1, "The replaced attempt's late answer is ignored");
|
|
1192
|
+
assert.equal(errors.length, 1);
|
|
1193
|
+
t.mock.timers.tick(5_000);
|
|
1194
|
+
assert.equal(errors.length, 2, "The replacement attempt has its own loading notice");
|
|
1195
|
+
subscribes[1].answer({ ok: true, streamEpoch: "current", state: slowState });
|
|
1196
|
+
assert.equal(states.length, 2);
|
|
1197
|
+
t.mock.timers.tick(300_000);
|
|
1198
|
+
assert.equal(errors.length, 2);
|
|
1199
|
+
assert.equal(subscribes.length, 2);
|
|
1200
|
+
assert.deepEqual(unsubscribes, []);
|
|
1201
|
+
subscription();
|
|
1202
|
+
});
|
|
1203
|
+
|
|
1079
1204
|
for (const status of [401, 403, 503]) {
|
|
1080
1205
|
test(`subscription read failure ${status} fences denied updates but retains transient history`, async t => {
|
|
1081
1206
|
const changes = new EventEmitter();
|