@cydm/happy-elves 0.1.0-beta.491 → 0.1.0-beta.494
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/apps/cli/package.json
CHANGED
|
@@ -84,6 +84,22 @@ export async function handleServerMessage(ws, config, message) {
|
|
|
84
84
|
capability: message.capability,
|
|
85
85
|
},
|
|
86
86
|
});
|
|
87
|
+
// A detached relay operation failure is recoverable only after the
|
|
88
|
+
// authenticated stream is recreated. Keeping this socket open would
|
|
89
|
+
// leave durable packets stranded: the relay has already rejected the
|
|
90
|
+
// replay/live send, while the daemon's normal outbox drain is only
|
|
91
|
+
// kicked on a new socket. Close the stream for storage-generation
|
|
92
|
+
// failures so start() performs its bounded reconnect loop. Do not do
|
|
93
|
+
// this for ordinary command validation errors.
|
|
94
|
+
if (shouldReconnectAfterRelayError(message.code) && ws.readyState === WebSocket.OPEN) {
|
|
95
|
+
try {
|
|
96
|
+
ws.close();
|
|
97
|
+
}
|
|
98
|
+
catch {
|
|
99
|
+
// The close event/error path still performs cleanup if the socket
|
|
100
|
+
// implementation rejects a redundant close.
|
|
101
|
+
}
|
|
102
|
+
}
|
|
87
103
|
return;
|
|
88
104
|
}
|
|
89
105
|
if (message.type === "machine:createSession") {
|
|
@@ -268,6 +284,14 @@ async function handleRelayWireMessage(ws, config, message) {
|
|
|
268
284
|
packetId: message.packetId,
|
|
269
285
|
},
|
|
270
286
|
});
|
|
287
|
+
if (shouldReconnectAfterRelayError(message.code) && ws.readyState === WebSocket.OPEN) {
|
|
288
|
+
try {
|
|
289
|
+
ws.close();
|
|
290
|
+
}
|
|
291
|
+
catch {
|
|
292
|
+
// Cleanup is also driven by the close/error listeners below.
|
|
293
|
+
}
|
|
294
|
+
}
|
|
271
295
|
return;
|
|
272
296
|
}
|
|
273
297
|
if (message.type !== "server:packet")
|
|
@@ -338,6 +362,9 @@ async function handleRelayWireMessage(ws, config, message) {
|
|
|
338
362
|
ackIncomingPacket(ws, message);
|
|
339
363
|
}
|
|
340
364
|
}
|
|
365
|
+
function shouldReconnectAfterRelayError(code) {
|
|
366
|
+
return code === "RELAY_STORAGE_TIMEOUT" || code === "RELAY_STORAGE_UNAVAILABLE" || code === "RELAY_STORAGE_CLOSED";
|
|
367
|
+
}
|
|
341
368
|
function errorCode(error) {
|
|
342
369
|
if (!error || typeof error !== "object")
|
|
343
370
|
return undefined;
|
package/apps/daemon/package.json
CHANGED
|
@@ -72,10 +72,21 @@ export function initPacketRelayTables(db) {
|
|
|
72
72
|
|
|
73
73
|
CREATE INDEX IF NOT EXISTS idx_packets_to_node
|
|
74
74
|
ON packets(space_id, to_node_id, id);
|
|
75
|
+
-- Replay admission must be able to reject already-acked packets without
|
|
76
|
+
-- touching the encrypted payload column. The old query selected
|
|
77
|
+
-- packets.* while walking every packet addressed to a node, so a node
|
|
78
|
+
-- with a large, fully-acked history could read gigabytes of overflow
|
|
79
|
+
-- pages before returning an empty batch.
|
|
80
|
+
CREATE INDEX IF NOT EXISTS idx_packets_unacked_lookup
|
|
81
|
+
ON packets(space_id, to_node_id, id, expires_at, packet_id);
|
|
75
82
|
CREATE INDEX IF NOT EXISTS idx_packets_channel
|
|
76
83
|
ON packets(space_id, channel_id, stream_id, id);
|
|
84
|
+
CREATE INDEX IF NOT EXISTS idx_packets_channel_id
|
|
85
|
+
ON packets(space_id, channel_id, id);
|
|
77
86
|
CREATE INDEX IF NOT EXISTS idx_packets_expires
|
|
78
87
|
ON packets(expires_at);
|
|
88
|
+
CREATE INDEX IF NOT EXISTS idx_packets_received_at
|
|
89
|
+
ON packets(received_at);
|
|
79
90
|
CREATE INDEX IF NOT EXISTS idx_retained_space_key
|
|
80
91
|
ON retained_packets(space_id, retained_key);
|
|
81
92
|
`);
|
|
@@ -192,24 +203,62 @@ export function listRetainedPackets(db, input) {
|
|
|
192
203
|
export function listPacketsForSubscription(db, input) {
|
|
193
204
|
const cursor = input.cursor ?? 0;
|
|
194
205
|
const limit = input.limit ?? 500;
|
|
195
|
-
const
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
206
|
+
const streamId = input.subscription.streamId;
|
|
207
|
+
const rows = [];
|
|
208
|
+
// Keep the historical OR semantics, but execute each arm independently so
|
|
209
|
+
// SQLite can seek through idx_packets_to_node or idx_packets_channel instead
|
|
210
|
+
// of scanning and sorting every packet in the account. A packet can match
|
|
211
|
+
// both arms; the merge below de-duplicates by the stable packets.id cursor.
|
|
212
|
+
// The node arm is intentionally unconditional. `nodeId` is the authenticated
|
|
213
|
+
// connection identity and was always part of the old OR predicate, even for
|
|
214
|
+
// an empty/channel-only subscription. `subscription.toNodeId` is a snapshot
|
|
215
|
+
// label, not a permission to redirect this reader to another node.
|
|
216
|
+
{
|
|
217
|
+
const sql = streamId === undefined
|
|
218
|
+
? `SELECT * FROM packets
|
|
219
|
+
WHERE space_id = ? AND to_node_id = ? AND id > ?
|
|
220
|
+
AND (expires_at IS NULL OR expires_at > ?)
|
|
221
|
+
ORDER BY id ASC LIMIT ?`
|
|
222
|
+
: `SELECT * FROM packets
|
|
223
|
+
WHERE space_id = ? AND to_node_id = ? AND stream_id = ? AND id > ?
|
|
224
|
+
AND (expires_at IS NULL OR expires_at > ?)
|
|
225
|
+
ORDER BY id ASC LIMIT ?`;
|
|
226
|
+
const values = streamId === undefined
|
|
227
|
+
? [input.spaceId, input.nodeId, cursor, input.now, limit]
|
|
228
|
+
: [input.spaceId, input.nodeId, streamId, cursor, input.now, limit];
|
|
229
|
+
rows.push(...statement(db, sql).all(...values));
|
|
230
|
+
}
|
|
231
|
+
if (input.subscription.channelId !== undefined) {
|
|
232
|
+
const sql = streamId === undefined
|
|
233
|
+
? `SELECT * FROM packets
|
|
234
|
+
WHERE space_id = ? AND channel_id = ? AND id > ?
|
|
235
|
+
AND (expires_at IS NULL OR expires_at > ?)
|
|
236
|
+
ORDER BY id ASC LIMIT ?`
|
|
237
|
+
: `SELECT * FROM packets
|
|
238
|
+
WHERE space_id = ? AND channel_id = ? AND stream_id = ? AND id > ?
|
|
239
|
+
AND (expires_at IS NULL OR expires_at > ?)
|
|
240
|
+
ORDER BY id ASC LIMIT ?`;
|
|
241
|
+
const values = streamId === undefined
|
|
242
|
+
? [input.spaceId, input.subscription.channelId, cursor, input.now, limit]
|
|
243
|
+
: [input.spaceId, input.subscription.channelId, streamId, cursor, input.now, limit];
|
|
244
|
+
rows.push(...statement(db, sql).all(...values));
|
|
245
|
+
}
|
|
246
|
+
const uniqueRows = new Map();
|
|
247
|
+
for (const row of rows)
|
|
248
|
+
uniqueRows.set(row.id, row);
|
|
249
|
+
return [...uniqueRows.values()]
|
|
250
|
+
.sort((left, right) => left.id - right.id)
|
|
251
|
+
.slice(0, limit)
|
|
252
|
+
.map(packetFromRow);
|
|
208
253
|
}
|
|
209
254
|
export function listUnackedPacketsForNode(db, input) {
|
|
210
255
|
const cursor = input.cursor ?? 0;
|
|
211
256
|
const limit = input.limit ?? 500;
|
|
212
|
-
|
|
257
|
+
// First find candidate row ids using a covering routing index. Do not
|
|
258
|
+
// select packets.* in this anti-join: encrypted_payload is commonly a
|
|
259
|
+
// multi-kilobyte value stored in overflow pages, and loading it for every
|
|
260
|
+
// already-acked packet turns an empty replay into a large disk read.
|
|
261
|
+
const candidateRows = statement(db, `SELECT packets.id
|
|
213
262
|
FROM packets
|
|
214
263
|
LEFT JOIN packet_acks
|
|
215
264
|
ON packet_acks.space_id = packets.space_id
|
|
@@ -222,13 +271,42 @@ export function listUnackedPacketsForNode(db, input) {
|
|
|
222
271
|
AND packet_acks.packet_id IS NULL
|
|
223
272
|
ORDER BY packets.id ASC
|
|
224
273
|
LIMIT ?`).all(input.nodeId, input.spaceId, cursor, input.nodeId, input.now, limit);
|
|
274
|
+
if (candidateRows.length === 0)
|
|
275
|
+
return [];
|
|
276
|
+
// Fetch the encrypted rows only for the bounded set that really needs to
|
|
277
|
+
// be replayed. The ids are already ordered, but retain an explicit ORDER
|
|
278
|
+
// BY so the FIFO/server-sequence contract remains clear to SQLite.
|
|
279
|
+
const placeholders = candidateRows.map(() => "?").join(", ");
|
|
280
|
+
const rows = statement(db, `SELECT * FROM packets
|
|
281
|
+
WHERE space_id = ? AND id IN (${placeholders})
|
|
282
|
+
ORDER BY id ASC`).all(input.spaceId, ...candidateRows.map((row) => row.id));
|
|
225
283
|
return rows.map(packetFromRow);
|
|
226
284
|
}
|
|
227
285
|
export function prunePackets(db, input) {
|
|
228
286
|
if (input.maxAgeMs <= 0)
|
|
229
287
|
return;
|
|
230
288
|
const oldestReceivedAt = input.now - input.maxAgeMs;
|
|
231
|
-
|
|
289
|
+
// Retention runs on the same serialized storage worker as packet replay.
|
|
290
|
+
// Bound each pass so a large historical database cannot occupy the worker
|
|
291
|
+
// past the operation deadline. The two indexed arms avoid the old OR
|
|
292
|
+
// predicate's full packets-table scan when there is nothing to prune.
|
|
293
|
+
const candidates = statement(db, `WITH expired AS (
|
|
294
|
+
SELECT id FROM packets
|
|
295
|
+
WHERE expires_at IS NOT NULL AND expires_at <= ?
|
|
296
|
+
LIMIT 1000
|
|
297
|
+
), stale AS (
|
|
298
|
+
SELECT id FROM packets
|
|
299
|
+
WHERE received_at <= ?
|
|
300
|
+
LIMIT 1000
|
|
301
|
+
)
|
|
302
|
+
SELECT id
|
|
303
|
+
FROM (SELECT id FROM expired UNION SELECT id FROM stale)
|
|
304
|
+
ORDER BY id ASC
|
|
305
|
+
LIMIT 1000`).all(input.now, oldestReceivedAt);
|
|
306
|
+
if (candidates.length === 0)
|
|
307
|
+
return;
|
|
308
|
+
const placeholders = candidates.map(() => "?").join(", ");
|
|
309
|
+
statement(db, `DELETE FROM packets WHERE id IN (${placeholders})`).run(...candidates.map((row) => row.id));
|
|
232
310
|
}
|
|
233
311
|
export function maxPacketSeq(db, spaceId) {
|
|
234
312
|
const row = statement(db, "SELECT COALESCE(MAX(id), 0) AS seq FROM packets WHERE space_id = ?").get(spaceId);
|
|
@@ -13,6 +13,7 @@ import { ackPacket, getPacketById, getRetainedPacket, insertPacket, listPacketsF
|
|
|
13
13
|
if (parentPort && workerData?.relayStorageWorker === true) {
|
|
14
14
|
const dbPath = String(workerData.dbPath);
|
|
15
15
|
const testStorageDelayMs = Math.max(0, Number(workerData.testStorageDelayMs) || 0);
|
|
16
|
+
const testStorageDelayFile = typeof workerData.testStorageDelayFile === "string" ? workerData.testStorageDelayFile : undefined;
|
|
16
17
|
mkdirSync(path.dirname(dbPath), { recursive: true });
|
|
17
18
|
const db = new DatabaseSync(dbPath);
|
|
18
19
|
db.exec("PRAGMA journal_mode = WAL");
|
|
@@ -26,8 +27,9 @@ if (parentPort && workerData?.relayStorageWorker === true) {
|
|
|
26
27
|
parentPort.postMessage({ type: "ready" });
|
|
27
28
|
parentPort.on("message", (request) => {
|
|
28
29
|
try {
|
|
29
|
-
if (testStorageDelayMs > 0)
|
|
30
|
+
if (testStorageDelayMs > 0 && (!testStorageDelayFile || existsSync(testStorageDelayFile))) {
|
|
30
31
|
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, testStorageDelayMs);
|
|
32
|
+
}
|
|
31
33
|
const value = request.op === "controlPlane"
|
|
32
34
|
? runControlPlaneAction(controlPlane, request.action, request.input)
|
|
33
35
|
: request.op === "directTicketCreate"
|
|
@@ -252,6 +254,7 @@ export function createRelayDurableStorage(dbPath) {
|
|
|
252
254
|
dbPath,
|
|
253
255
|
pairingTtlMs: 600_000,
|
|
254
256
|
testStorageDelayMs: process.env.HAPPY_ELVES_TEST_STORAGE_DELAY_MS,
|
|
257
|
+
testStorageDelayFile: process.env.HAPPY_ELVES_TEST_STORAGE_DELAY_FILE,
|
|
255
258
|
},
|
|
256
259
|
});
|
|
257
260
|
nextWorker.unref();
|
|
@@ -158,9 +158,12 @@ export function registerWebsocketRoute(app, context, originAllowed) {
|
|
|
158
158
|
});
|
|
159
159
|
await sendSnapshot(context, openedConnection, message.snapshotMode ?? "full");
|
|
160
160
|
broadcastPresence(context, openedConnection.spaceId, await presenceWithManifest(context, openedConnection.spaceId, presence));
|
|
161
|
-
void replayNodePackets(context, openedConnection, message.cursor)
|
|
161
|
+
void replayNodePackets(context, openedConnection, message.cursor).catch((error) => {
|
|
162
|
+
reportDetachedOperationFailure(openedConnection, error);
|
|
163
|
+
});
|
|
162
164
|
}).catch((error) => {
|
|
163
165
|
app.log.warn({ error }, "failed to persist relay presence");
|
|
166
|
+
reportDetachedOperationFailure(openedConnection, error, true);
|
|
164
167
|
});
|
|
165
168
|
}).catch((error) => {
|
|
166
169
|
fail(error instanceof Error ? error.message : "Websocket authentication failed", "AUTH_TOKEN_INVALID");
|
|
@@ -210,10 +213,15 @@ export function registerWebsocketRoute(app, context, originAllowed) {
|
|
|
210
213
|
if (!connection)
|
|
211
214
|
return;
|
|
212
215
|
const closedConnection = connection;
|
|
216
|
+
closedConnection.closed = true;
|
|
213
217
|
closedConnection.publishChainClosed = true;
|
|
214
218
|
if (closedConnection.expiryTimer)
|
|
215
219
|
globalThis.clearTimeout(closedConnection.expiryTimer);
|
|
216
|
-
|
|
220
|
+
if (closedConnection.cursorFlushTimer) {
|
|
221
|
+
globalThis.clearTimeout(closedConnection.cursorFlushTimer);
|
|
222
|
+
closedConnection.cursorFlushTimer = undefined;
|
|
223
|
+
}
|
|
224
|
+
void flushPendingCursor(context, closedConnection).catch(() => undefined);
|
|
217
225
|
unregisterConnection(closedConnection);
|
|
218
226
|
const remaining = connectionsByNode.get(nodeKey(closedConnection.spaceId, closedConnection.nodeId));
|
|
219
227
|
const stillOnline = Boolean(remaining && remaining.size > 0);
|
|
@@ -544,7 +552,9 @@ function pumpSubscriptionReplays(context, connection) {
|
|
|
544
552
|
if (!state || state.running)
|
|
545
553
|
continue;
|
|
546
554
|
state.queued = false;
|
|
547
|
-
void runSubscriptionReplay(context, connection, key, state)
|
|
555
|
+
void runSubscriptionReplay(context, connection, key, state).catch((error) => {
|
|
556
|
+
reportDetachedOperationFailure(connection, error);
|
|
557
|
+
});
|
|
548
558
|
}
|
|
549
559
|
}
|
|
550
560
|
async function runSubscriptionReplay(context, connection, key, state) {
|
|
@@ -613,7 +623,9 @@ function deliverPacket(context, packet) {
|
|
|
613
623
|
if (packet.toNodeId) {
|
|
614
624
|
const connections = connectionsByNode.get(nodeKey(packet.spaceId, packet.toNodeId));
|
|
615
625
|
for (const connection of connections ?? []) {
|
|
616
|
-
void sendPacket(context, connection, packet, "live")
|
|
626
|
+
void sendPacket(context, connection, packet, "live").catch((error) => {
|
|
627
|
+
reportDetachedOperationFailure(connection, error);
|
|
628
|
+
});
|
|
617
629
|
delivered.add(connection);
|
|
618
630
|
}
|
|
619
631
|
}
|
|
@@ -623,7 +635,9 @@ function deliverPacket(context, packet) {
|
|
|
623
635
|
continue;
|
|
624
636
|
if (!connection.subscriptions.some((subscription) => subscriptionMatches(connection, subscription, packet)))
|
|
625
637
|
continue;
|
|
626
|
-
void sendPacket(context, connection, packet, "live")
|
|
638
|
+
void sendPacket(context, connection, packet, "live").catch((error) => {
|
|
639
|
+
reportDetachedOperationFailure(connection, error);
|
|
640
|
+
});
|
|
627
641
|
}
|
|
628
642
|
}
|
|
629
643
|
async function sendPacket(context, connection, packet, source) {
|
|
@@ -666,6 +680,8 @@ async function sendPacket(context, connection, packet, source) {
|
|
|
666
680
|
return true;
|
|
667
681
|
}
|
|
668
682
|
function queueCursorUpdate(context, connection, serverSeq) {
|
|
683
|
+
if (connection.closed)
|
|
684
|
+
return;
|
|
669
685
|
connection.pendingCursorServerSeq = Math.max(connection.pendingCursorServerSeq ?? 0, serverSeq);
|
|
670
686
|
if (connection.cursorFlushTimer)
|
|
671
687
|
return;
|
|
@@ -674,13 +690,35 @@ function queueCursorUpdate(context, connection, serverSeq) {
|
|
|
674
690
|
// keeps high-rate replay from turning every packet into an fsync.
|
|
675
691
|
connection.cursorFlushTimer = setTimeout(() => {
|
|
676
692
|
connection.cursorFlushTimer = undefined;
|
|
677
|
-
void flushPendingCursor(context, connection);
|
|
693
|
+
void flushPendingCursor(context, connection).catch(() => undefined);
|
|
678
694
|
}, 100);
|
|
679
695
|
connection.cursorFlushTimer.unref?.();
|
|
680
696
|
}
|
|
681
697
|
async function flushPendingCursor(context, connection) {
|
|
698
|
+
if (connection.cursorFlushInFlight)
|
|
699
|
+
return await connection.cursorFlushInFlight;
|
|
700
|
+
const run = flushPendingCursorOnce(context, connection);
|
|
701
|
+
let trackedRun;
|
|
702
|
+
trackedRun = run.finally(() => {
|
|
703
|
+
if (connection.cursorFlushInFlight === trackedRun)
|
|
704
|
+
connection.cursorFlushInFlight = undefined;
|
|
705
|
+
// A newer packet can arrive while the write is in flight. Keep one
|
|
706
|
+
// coalesced follow-up timer, but never resurrect retries after the
|
|
707
|
+
// socket has closed.
|
|
708
|
+
if (!connection.closed && connection.pendingCursorServerSeq !== undefined && !connection.cursorFlushTimer) {
|
|
709
|
+
connection.cursorFlushTimer = setTimeout(() => {
|
|
710
|
+
connection.cursorFlushTimer = undefined;
|
|
711
|
+
void flushPendingCursor(context, connection).catch(() => undefined);
|
|
712
|
+
}, 250);
|
|
713
|
+
connection.cursorFlushTimer.unref?.();
|
|
714
|
+
}
|
|
715
|
+
});
|
|
716
|
+
connection.cursorFlushInFlight = trackedRun;
|
|
717
|
+
await trackedRun;
|
|
718
|
+
}
|
|
719
|
+
async function flushPendingCursorOnce(context, connection) {
|
|
682
720
|
if (connection.cursorFlushTimer) {
|
|
683
|
-
clearTimeout(connection.cursorFlushTimer);
|
|
721
|
+
globalThis.clearTimeout(connection.cursorFlushTimer);
|
|
684
722
|
connection.cursorFlushTimer = undefined;
|
|
685
723
|
}
|
|
686
724
|
const serverSeq = connection.pendingCursorServerSeq;
|
|
@@ -700,7 +738,8 @@ async function flushPendingCursor(context, connection) {
|
|
|
700
738
|
connection.pendingCursorServerSeq = Math.max(connection.pendingCursorServerSeq ?? 0, serverSeq);
|
|
701
739
|
// Cursor persistence is recoverable; do not terminate the relay process
|
|
702
740
|
// because a worker/database write was temporarily unavailable.
|
|
703
|
-
|
|
741
|
+
// The single-flight finalizer above owns the one retry timer. In
|
|
742
|
+
// particular, a close cannot leave an unbounded timer chain behind.
|
|
704
743
|
}
|
|
705
744
|
}
|
|
706
745
|
function closeSlowReceiver(connection) {
|
|
@@ -874,7 +913,7 @@ function scheduleConnectionExpiry(context, connection, expiresAt) {
|
|
|
874
913
|
if (!current || current.revoked_at)
|
|
875
914
|
return;
|
|
876
915
|
if (current.expires_at && current.expires_at > context.now()) {
|
|
877
|
-
void scheduleNext();
|
|
916
|
+
void scheduleNext().catch(() => undefined);
|
|
878
917
|
return;
|
|
879
918
|
}
|
|
880
919
|
if (!current.expires_at)
|
|
@@ -888,7 +927,33 @@ function scheduleConnectionExpiry(context, connection, expiresAt) {
|
|
|
888
927
|
}).catch(() => undefined);
|
|
889
928
|
}, Math.min(delay, MAX_EXPIRY_TIMER_DELAY_MS));
|
|
890
929
|
};
|
|
891
|
-
void scheduleNext();
|
|
930
|
+
void scheduleNext().catch(() => undefined);
|
|
931
|
+
}
|
|
932
|
+
function reportDetachedOperationFailure(connection, error, forceClose = false) {
|
|
933
|
+
if (connection.socket.readyState !== 1)
|
|
934
|
+
return;
|
|
935
|
+
const record = error && typeof error === "object" ? error : {};
|
|
936
|
+
const code = typeof record.code === "string" ? record.code : "RELAY_COMMAND_FAILED";
|
|
937
|
+
const message = typeof record.message === "string" ? record.message : String(error);
|
|
938
|
+
send(connection.socket, { type: "server:error", code, message });
|
|
939
|
+
// A detached replay/live send has already lost its in-memory continuation.
|
|
940
|
+
// Close the stream for storage-generation failures so old daemons and
|
|
941
|
+
// controllers, which only reconnect on close, replay their durable outbox
|
|
942
|
+
// and retained packets. The stored ACK boundary remains authoritative; no
|
|
943
|
+
// packet is acknowledged merely because the connection is being closed.
|
|
944
|
+
if (forceClose || isRecoverableStorageFailure(code)) {
|
|
945
|
+
connection.closed = true;
|
|
946
|
+
try {
|
|
947
|
+
connection.socket.close(1011, code);
|
|
948
|
+
}
|
|
949
|
+
catch {
|
|
950
|
+
// The close/error listener still performs cleanup for transports that
|
|
951
|
+
// reject a redundant close.
|
|
952
|
+
}
|
|
953
|
+
}
|
|
954
|
+
}
|
|
955
|
+
function isRecoverableStorageFailure(code) {
|
|
956
|
+
return code === "RELAY_STORAGE_TIMEOUT" || code === "RELAY_STORAGE_UNAVAILABLE" || code === "RELAY_STORAGE_CLOSED";
|
|
892
957
|
}
|
|
893
958
|
async function isScopedControllerConnection(context, connection) {
|
|
894
959
|
const token = await context.tokenRecordAsync(connection.authToken);
|