@staix/agent-hub 0.32.0 → 0.34.0
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/CHANGELOG.md +33 -0
- package/README.md +2 -2
- package/docs/design/bus.md +68 -2
- package/docs/dogfood.md +3 -1
- package/docs/memory.md +9 -3
- package/docs/operations.md +124 -20
- package/docs/quickstart.md +32 -2
- package/docs/security.md +28 -9
- package/docs/specs/2026-09-19-agent-hub-design.md +36 -6
- package/docs/specs/2026-09-20-upgrade-recovery-design.md +7 -3
- package/docs/verification/2026-11-06-0.33.0.md +222 -0
- package/docs/verification/2026-11-06-claude-implementer-2.1.319.md +57 -0
- package/docs/verification/2026-11-06-codex-0167-reviewer-isolation.md +56 -0
- package/docs/verification/2026-11-06-codex-0167-reviewer-tool-call.md +66 -0
- package/docs/verification/2026-11-06-kimi-251-reviewer-compat.md +88 -0
- package/docs/verification/2026-11-06-orca-1.4.250-cli-contract.md +50 -0
- package/docs/verification/2026-11-06-qwen-0268-acp.md +75 -0
- package/docs/verification/2026-11-06-qwen-0268-dual-output.md +44 -0
- package/docs/verification/2026-11-07-0.34.0.md +71 -0
- package/docs/verification/2026-11-07-compat-receipt-audit-1248.md +256 -0
- package/docs/verification/claude-shell-wrapper-native.py +1 -1
- package/docs/verified.json +11 -11
- package/package.json +2 -2
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +528 -206
- package/src/adapters/compat.ts +11 -11
- package/src/adapters/definitions/native-cli-claude-setup.ts +14 -5
- package/src/broker/protocol.ts +5 -0
- package/src/broker/server.ts +54 -4
- package/src/broker/store.ts +6 -3
- package/src/cli/autonomy-homes.ts +12 -3
- package/src/cli/claude-homes.ts +5 -5
- package/src/cli/claude-profile-hook.ts +38 -9
- package/src/cli/install-paths.ts +71 -0
- package/src/cli/main.ts +20 -1
- package/src/cli/output.ts +5 -1
- package/src/cli/room.ts +17 -5
- package/src/cli/runtime-version-warning.ts +8 -6
- package/src/cli/setup-homes.ts +13 -10
- package/src/cli/status-lines.ts +8 -1
- package/src/cli/terminal-recovery.ts +96 -18
- package/src/cli/upgrade-interactive.ts +29 -6
- package/src/cli/upgrade-output.ts +3 -2
- package/src/cli/upgrade-runtime.ts +34 -4
- package/src/cli/upgrade.ts +3 -1
- package/src/hub/bus.ts +7 -0
- package/src/hub/daemon.ts +13 -4
- package/src/hub/handoff.ts +1 -1
- package/src/rooms/application.ts +15 -0
- package/src/rooms/commands.ts +76 -16
- package/src/rooms/health.ts +32 -0
- package/src/rooms/link.ts +267 -43
- package/src/rooms/remote-peer.ts +18 -3
- package/src/rooms/service.ts +27 -12
- package/src/rooms/status.ts +57 -1
- package/src/rooms/store.ts +20 -1
- package/src/rooms/wire.ts +10 -8
- package/src/ui/index.html +1 -1
package/src/rooms/link.ts
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
/** Outbound room transport. The local bus and application delivery boundary belong to X4. */
|
|
2
|
-
import { createBrokerAuth, decodeBrokerChallenge, encodeBrokerControl } from "../broker/protocol.ts";
|
|
2
|
+
import { BROKER_CODEC_FEATURES_OPT_IN, createBrokerAuth, decodeBrokerChallenge, encodeBrokerControl } from "../broker/protocol.ts";
|
|
3
3
|
import { assertRoomCryptoReady, encodeBase64url, sha256, RoomCryptoError } from "./crypto.ts";
|
|
4
4
|
import { brokerUrl, canonicalBytes, decodeFrame, decodeRoster, openFrame, roomMembers, sealFrame, verifyPublicRoster, RoomWireError, type FrameWire, type FrameOpenResult, type VerifiedPublicRoster } from "./wire.ts";
|
|
5
5
|
import type { RoomRefusalReason, RoomState, RoomStore } from "./store.ts";
|
|
6
6
|
import { RoomUnmatchedReceiptError, RoomFreshChatCapacityError, RoomStoreError, type RoomOutgoingInput } from "./store.ts";
|
|
7
7
|
import { ROOM_STOP_TIMEOUT_MS, roomRefreshPeriod } from "./liveness.ts";
|
|
8
|
-
import {
|
|
8
|
+
import { roomHealthCause, roomHealthNextStep, type RoomHealthCause } from "./health.ts";
|
|
9
|
+
import { ROOM_CODEC_FEATURES, roomCodecFeatures, roomRequiredCodecFeature, decodeRoomMessage, encodeRoomMessage, type RoomCodecFeature, type RoomMessage } from "./application.ts";
|
|
9
10
|
|
|
10
11
|
export type RoomLinkState = "offline" | "connecting" | "online";
|
|
11
12
|
export type RoomPresenceState = "idle" | "busy" | "paused" | "offline";
|
|
@@ -14,7 +15,7 @@ export interface RoomLinkOptions {
|
|
|
14
15
|
store: RoomStore;
|
|
15
16
|
name: string;
|
|
16
17
|
presence?: () => readonly { id: string; state: string }[];
|
|
17
|
-
onPresence?: (value: { member: string; peers: readonly RoomPresencePeer[]; restored?: true }) => void;
|
|
18
|
+
onPresence?: (value: { member: string; peers: readonly RoomPresencePeer[]; restored?: true; observedAt?: number | null }) => void;
|
|
18
19
|
onState?: (state: RoomLinkState) => void;
|
|
19
20
|
application?: RoomLinkApplication;
|
|
20
21
|
refreshMs?: number;
|
|
@@ -33,7 +34,7 @@ export interface RoomLinkApplication {
|
|
|
33
34
|
transportAck?(context: RoomLinkContext, input: { digest: string }): void;
|
|
34
35
|
receiptTtlMs?(context: RoomLinkContext): number;
|
|
35
36
|
capacityChanged?(context: RoomLinkContext): void;
|
|
36
|
-
|
|
37
|
+
codecsChanged?(context: RoomLinkContext, input: { member: string; features?: readonly RoomCodecFeature[] }): void;
|
|
37
38
|
}
|
|
38
39
|
/** Host port signal: no application claim/effect occurred; the durable plaintext remains pending. */
|
|
39
40
|
export class RoomApplicationDeferredError extends Error {
|
|
@@ -45,17 +46,32 @@ export class RoomLinkError extends Error {
|
|
|
45
46
|
}
|
|
46
47
|
}
|
|
47
48
|
/** Only before durable application enqueue: the Bus may retain its original queue item. */
|
|
49
|
+
export class RoomCodecUnsupportedError extends Error {
|
|
50
|
+
readonly code = "codec_unsupported";
|
|
51
|
+
constructor(readonly feature: RoomCodecFeature) {
|
|
52
|
+
super(`Room send refused: recipient codec support for ${feature} is unavailable; upgrade both hubs and the broker, reconnect the room, then retry.`);
|
|
53
|
+
this.name = "RoomCodecUnsupportedError";
|
|
54
|
+
}
|
|
55
|
+
}
|
|
48
56
|
export class RoomNotSentError extends Error {
|
|
49
57
|
readonly code = "not_sent";
|
|
50
58
|
constructor() { super("room application was not queued; retry when the link is ready"); this.name = "RoomNotSentError"; }
|
|
51
59
|
}
|
|
60
|
+
/** No compatibility observation exists yet; retain work under its original queue deadline. */
|
|
61
|
+
export class RoomCodecPendingError extends RoomNotSentError {
|
|
62
|
+
constructor(readonly changed: () => boolean) {
|
|
63
|
+
super(); this.name = "RoomCodecPendingError";
|
|
64
|
+
this.message = "Room codec observation is pending; wait for a signed codec advertisement, then retry.";
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
export type RoomCodecState = "pending" | "supported" | "unsupported";
|
|
52
68
|
const RECEIPT_TTL_MS = 24 * 3_600_000;
|
|
53
69
|
const LIMITS = Object.freeze({ packets: 8, queuedBytes: 2_097_152, packetBytes: 1_048_576, backpressure: 1_048_576, handshakeMs: 30_000, reconnectMaxMs: 300_000 });
|
|
54
70
|
const decoder = new TextDecoder("utf-8", { fatal: true });
|
|
55
71
|
const PEER_ID = /^[a-z][a-z0-9-]{0,31}$/;
|
|
56
72
|
const DIGEST = /^[A-Za-z0-9_-]{43}$/;
|
|
57
73
|
const STATES: readonly string[] = ["idle", "busy", "paused", "offline"];
|
|
58
|
-
interface PendingFrame { wire: Uint8Array; digest: string; presence: boolean; requestReply: boolean; current: () => boolean; retained?: () => boolean; onSent?: () => void; afterInboundAck?: string }
|
|
74
|
+
interface PendingFrame { wire: Uint8Array; digest: string; presence: boolean; requestReply: boolean; current: () => boolean; retained?: () => boolean; onSent?: () => void; afterInboundAck?: string; codecFeatures?: boolean; supported?: () => boolean; codecPending?: () => boolean; codecTarget?: { member: string; feature: RoomCodecFeature } }
|
|
59
75
|
interface Session {
|
|
60
76
|
closingHead?: PendingFrame;
|
|
61
77
|
refresh?: ReturnType<typeof setTimeout>;
|
|
@@ -72,6 +88,13 @@ interface Session {
|
|
|
72
88
|
frameRetry?: ReturnType<typeof setTimeout>;
|
|
73
89
|
lastPresence?: string;
|
|
74
90
|
ephemeral?: boolean;
|
|
91
|
+
codecFeatures?: boolean;
|
|
92
|
+
codecSession?: string;
|
|
93
|
+
codecPublishing?: Promise<void>;
|
|
94
|
+
codecDirty?: boolean;
|
|
95
|
+
pendingPresence?: Map<string, { session: string | null; epoch: string; peers: RoomPresencePeer[]; observedAt: number | null }>;
|
|
96
|
+
codecPeers?: Map<string, { session: string | null; supported: boolean }>;
|
|
97
|
+
features?: Map<string, { session: string; features: readonly string[] }>;
|
|
75
98
|
ws: WebSocket;
|
|
76
99
|
generation: number;
|
|
77
100
|
room: string;
|
|
@@ -106,7 +129,8 @@ function packet(bytes: Uint8Array): Record<string, unknown> {
|
|
|
106
129
|
return row;
|
|
107
130
|
}
|
|
108
131
|
function presence(bytes: Uint8Array, exposed: readonly string[]): RoomPresencePeer[] {
|
|
109
|
-
const decoded = packet(bytes), row = exact(decoded, Object.hasOwn(decoded, "requestReply") ? ["
|
|
132
|
+
const decoded = packet(bytes), row = exact(decoded, ["v", "type", "peers", ...(Object.hasOwn(decoded, "requestReply") ? ["requestReply"] : []), ...(Object.hasOwn(decoded, "features") ? ["features", "codecSession"] : [])]);
|
|
133
|
+
if (Object.hasOwn(row, "features")) { roomCodecFeatures(row.features); if (typeof row.codecSession !== "string" || !DIGEST.test(row.codecSession)) fail(); }
|
|
110
134
|
if (row.requestReply !== undefined && typeof row.requestReply !== "boolean") fail();
|
|
111
135
|
if (row.type !== "presence" || !Array.isArray(row.peers) || row.peers.length > 64) fail();
|
|
112
136
|
const peers = row.peers.map(value => {
|
|
@@ -131,6 +155,9 @@ export class RoomLink {
|
|
|
131
155
|
private reconnect?: ReturnType<typeof setTimeout>;
|
|
132
156
|
private attempts = 0;
|
|
133
157
|
private state: RoomLinkState = "offline";
|
|
158
|
+
private cause?: RoomHealthCause;
|
|
159
|
+
private blockedCodec?: { member: string; feature: RoomCodecFeature };
|
|
160
|
+
private clockHighWater?: number;
|
|
134
161
|
private publishing = false;
|
|
135
162
|
private publishDirty = false;
|
|
136
163
|
private replyPresence = false;
|
|
@@ -144,13 +171,33 @@ export class RoomLink {
|
|
|
144
171
|
|
|
145
172
|
/** Advisory transport state without copying or decoding a room epoch for a status poll. */
|
|
146
173
|
get connectionState(): RoomLinkState { return this.state; }
|
|
174
|
+
get connectionCause(): RoomHealthCause | undefined { return this.cause; }
|
|
147
175
|
/** Negotiated transport evidence only; unknown until this session is ready. */
|
|
176
|
+
codecState(recipient: string, feature: RoomCodecFeature): RoomCodecState {
|
|
177
|
+
const session = this.session;
|
|
178
|
+
if (!session || session.phase !== "ready") return "pending";
|
|
179
|
+
if (session.codecFeatures !== true) return "unsupported";
|
|
180
|
+
const peer = session.codecPeers?.get(recipient);
|
|
181
|
+
if (!peer || peer.session === null) return "pending";
|
|
182
|
+
if (!peer.supported) return "unsupported";
|
|
183
|
+
const advertised = session.features?.get(recipient);
|
|
184
|
+
if (!advertised || advertised.session !== peer.session) return "pending";
|
|
185
|
+
return advertised.features.includes(feature) ? "supported" : "unsupported";
|
|
186
|
+
}
|
|
187
|
+
codecSupported(recipient: string, feature: RoomCodecFeature): boolean { return this.codecState(recipient, feature) === "supported"; }
|
|
188
|
+
private codecRefusal(recipient: string, feature: RoomCodecFeature): RoomNotSentError | RoomCodecUnsupportedError {
|
|
189
|
+
if (this.codecState(recipient, feature) === "unsupported") return new RoomCodecUnsupportedError(feature);
|
|
190
|
+
const session = this.session, peer = session?.codecPeers?.get(recipient), advertisement = session?.features?.get(recipient);
|
|
191
|
+
return new RoomCodecPendingError(() => this.session !== session || session?.codecPeers?.get(recipient) !== peer
|
|
192
|
+
|| session?.features?.get(recipient) !== advertisement || this.codecState(recipient, feature) !== "pending");
|
|
193
|
+
}
|
|
148
194
|
get livenessTracked(): boolean | undefined { return this.session?.phase === "ready" ? this.session.ephemeral : undefined; }
|
|
149
|
-
status(): { state: RoomLinkState; members: number } {
|
|
195
|
+
status(): { state: RoomLinkState; members: number; cause?: RoomHealthCause; nextStep?: string } {
|
|
150
196
|
try {
|
|
151
197
|
const row = this.options.store.get(this.options.name);
|
|
152
|
-
return { state: this.state, members: row && !row.left ? roomMembers(row.current).length : 0
|
|
153
|
-
|
|
198
|
+
return { state: this.state, members: row && !row.left ? roomMembers(row.current).length : 0,
|
|
199
|
+
...(this.cause ? { cause: this.cause, nextStep: roomHealthNextStep(this.cause) } : {}) };
|
|
200
|
+
} catch (error) { const cause = roomHealthCause(error); return { state: "offline", members: 0, cause, nextStep: roomHealthNextStep(cause) }; }
|
|
154
201
|
}
|
|
155
202
|
async start(): Promise<void> {
|
|
156
203
|
if (this.running || this.stopping) return;
|
|
@@ -224,12 +271,12 @@ export class RoomLink {
|
|
|
224
271
|
if (session.refresh) clearTimeout(session.refresh);
|
|
225
272
|
// Legacy brokers retain state-change/dedupe publication without periodic traffic.
|
|
226
273
|
if (!session.ephemeral || !this.running || this.stopping) return;
|
|
227
|
-
session.refreshedAt =
|
|
274
|
+
session.refreshedAt = this.observationTime();
|
|
228
275
|
session.refresh = setTimeout(() => {
|
|
229
276
|
session.refresh = undefined;
|
|
230
277
|
if (this.session !== session || !this.running || this.stopping || session.phase !== "ready") return;
|
|
231
|
-
const now =
|
|
232
|
-
if (now - session.refreshedAt
|
|
278
|
+
const now = this.observationTime();
|
|
279
|
+
if (now !== undefined && session.refreshedAt !== undefined && now - session.refreshedAt >= this.refreshMs) {
|
|
233
280
|
session.pendingPresenceReplies.clear(); session.presenceAsked = false;
|
|
234
281
|
session.requestPresenceReply = true; this.forcePresenceRequest = true;
|
|
235
282
|
void this.publishPresence().catch(() => this.retry(session));
|
|
@@ -238,15 +285,35 @@ export class RoomLink {
|
|
|
238
285
|
}, this.refreshMs);
|
|
239
286
|
session.refresh.unref?.();
|
|
240
287
|
}
|
|
241
|
-
private
|
|
242
|
-
|
|
243
|
-
this.
|
|
288
|
+
private observationTime(): number | undefined {
|
|
289
|
+
let now: number;
|
|
290
|
+
try { now = (this.options.now ?? Date.now)(); } catch { this.setState(this.state, "unavailable"); return undefined; }
|
|
291
|
+
if (!Number.isSafeInteger(now) || now < 0) { this.setState(this.state, "unavailable"); return undefined; }
|
|
292
|
+
if (this.clockHighWater !== undefined && now < this.clockHighWater) { this.setState(this.state, "clock_rollback"); return undefined; }
|
|
293
|
+
this.clockHighWater = now;
|
|
294
|
+
return now;
|
|
295
|
+
}
|
|
296
|
+
/** Called only after the Store-backed sequence port committed a reservation. */
|
|
297
|
+
private clockRecoveredAfterWrite(): void {
|
|
298
|
+
if (this.cause === "clock_rollback" && this.observationTime() !== undefined) this.setState(this.state);
|
|
299
|
+
}
|
|
300
|
+
private refreshCodecCause(member: string): void {
|
|
301
|
+
const blocked = this.blockedCodec;
|
|
302
|
+
if (!blocked || blocked.member !== member) return;
|
|
303
|
+
const compatibility = this.codecState(member, blocked.feature);
|
|
304
|
+
if (compatibility === "supported") this.setState(this.state);
|
|
305
|
+
else this.setState(this.state, compatibility === "pending" ? "codec_pending" : "codec_unsupported");
|
|
306
|
+
}
|
|
307
|
+
private setState(state: RoomLinkState, cause?: RoomHealthCause): void {
|
|
308
|
+
if (cause !== "codec_pending" && cause !== "codec_unsupported") this.blockedCodec = undefined;
|
|
309
|
+
if (this.state === state && this.cause === cause) return;
|
|
310
|
+
this.state = state; this.cause = cause;
|
|
244
311
|
try { this.options.onState?.(state); } catch { /* observation never changes transport authority */ }
|
|
245
312
|
}
|
|
246
313
|
private current(session: Session, digest?: string, final = false): RoomState {
|
|
247
314
|
if ((!this.running && !(final && this.stopping)) || this.session !== session || session.generation !== this.generation || session.ws.readyState !== WebSocket.OPEN) fail("state_changed");
|
|
248
315
|
let row: RoomState | undefined;
|
|
249
|
-
try { row = this.options.store.get(this.options.name); } catch { fail("unavailable"); }
|
|
316
|
+
try { row = this.options.store.get(this.options.name); } catch (error) { this.setState(this.state, roomHealthCause(error)); fail("unavailable"); }
|
|
250
317
|
if (!row || row.left || row.current.room !== session.room || row.broker !== session.broker || (digest !== undefined && row.current.digest !== digest)) fail("state_changed");
|
|
251
318
|
return row;
|
|
252
319
|
}
|
|
@@ -324,7 +391,7 @@ export class RoomLink {
|
|
|
324
391
|
}).catch(() => undefined);
|
|
325
392
|
return work;
|
|
326
393
|
}
|
|
327
|
-
private disconnect(session = this.session): void {
|
|
394
|
+
private disconnect(session = this.session, cause?: RoomHealthCause): void {
|
|
328
395
|
if (session && session !== this.session) return;
|
|
329
396
|
this.generation++;
|
|
330
397
|
this.session = undefined;
|
|
@@ -340,12 +407,20 @@ export class RoomLink {
|
|
|
340
407
|
session.queue.length = 0; session.queuedBytes = 0; session.deferredFrame = undefined;
|
|
341
408
|
try { session.ws.close(1000, "disconnected"); } catch { /* already closed */ }
|
|
342
409
|
}
|
|
343
|
-
this.setState("offline");
|
|
410
|
+
this.setState("offline", cause);
|
|
344
411
|
}
|
|
345
|
-
private retry(session?: Session): void {
|
|
412
|
+
private retry(session?: Session, error?: unknown): void {
|
|
346
413
|
if (this.stopping) return;
|
|
347
414
|
if (session && this.session !== session) return;
|
|
348
|
-
|
|
415
|
+
let cause: RoomHealthCause = "transport_closed";
|
|
416
|
+
if (error instanceof RoomStoreError) cause = roomHealthCause(error);
|
|
417
|
+
else if (this.cause === "broker_refused" || this.cause === "clock_rollback") cause = this.cause;
|
|
418
|
+
else if (error instanceof RoomLinkError) {
|
|
419
|
+
if (error.code === "limit") cause = "backpressure";
|
|
420
|
+
else if (error.code === "invalid_wire") cause = "untrusted";
|
|
421
|
+
else cause = error.code;
|
|
422
|
+
} else if (this.cause === "backpressure") cause = this.cause;
|
|
423
|
+
this.disconnect(session, cause);
|
|
349
424
|
if (!this.running || this.reconnect) return;
|
|
350
425
|
const ceiling = Math.min(LIMITS.reconnectMaxMs, 1000 * 2 ** Math.min(this.attempts++, 9));
|
|
351
426
|
const delay = Math.floor(ceiling / 2 + Math.random() * ceiling / 2);
|
|
@@ -355,24 +430,24 @@ export class RoomLink {
|
|
|
355
430
|
private connect(): void {
|
|
356
431
|
if (!this.running || this.session) return;
|
|
357
432
|
let row: RoomState | undefined;
|
|
358
|
-
try { row = this.options.store.get(this.options.name); } catch { this.
|
|
433
|
+
try { row = this.options.store.get(this.options.name); } catch (error) { this.retry(undefined, error); return; }
|
|
359
434
|
if (!row || row.left) { this.setState("offline"); return; }
|
|
360
435
|
let url: URL;
|
|
361
436
|
try {
|
|
362
437
|
url = new URL(brokerUrl(row.broker));
|
|
363
438
|
if (url.pathname !== "/" || url.search) fail("untrusted");
|
|
364
|
-
} catch { this.setState("offline"); return; }
|
|
439
|
+
} catch { this.setState("offline", "untrusted"); return; }
|
|
365
440
|
url.pathname = `/v1/rooms/${row.current.room}`;
|
|
366
441
|
let ws: WebSocket;
|
|
367
|
-
//
|
|
368
|
-
try { ws = new WebSocket(url.href, { headers: { "x-agent-hub-ephemeral": "1" } }); } catch { this.retry(); return; }
|
|
442
|
+
// Preserve the released ephemeral header exactly; old brokers ignore the additional codec token.
|
|
443
|
+
try { ws = new WebSocket(url.href, { headers: { "x-agent-hub-ephemeral": "1", "x-agent-hub-codecs": BROKER_CODEC_FEATURES_OPT_IN } }); } catch { this.retry(); return; }
|
|
369
444
|
ws.binaryType = "arraybuffer";
|
|
370
445
|
const generation = ++this.generation;
|
|
371
446
|
const session: Session = { ws, generation, room: row.current.room, broker: row.broker, phase: "challenge", pendingPresenceReplies: new Set(), presenceAsked: false, outbound: [], outboundBytes: 0, frameSent: false, retryAttempt: 0, queue: [], queuedBytes: 0, processing: false, initialRoster: true, requestPresenceReply: true,
|
|
372
447
|
handshake: setTimeout(() => this.retry(session), LIMITS.handshakeMs) };
|
|
373
448
|
session.handshake.unref?.();
|
|
374
449
|
this.session = session;
|
|
375
|
-
this.setState("connecting");
|
|
450
|
+
this.setState("connecting", "connecting");
|
|
376
451
|
ws.addEventListener("message", event => this.receive(session, event.data));
|
|
377
452
|
ws.addEventListener("close", () => this.retry(session));
|
|
378
453
|
ws.addEventListener("error", () => this.retry(session));
|
|
@@ -382,15 +457,15 @@ export class RoomLink {
|
|
|
382
457
|
if (bytes.length > LIMITS.packetBytes || session.ws.bufferedAmount > LIMITS.backpressure) fail("limit");
|
|
383
458
|
session.ws.send(bytes);
|
|
384
459
|
}
|
|
385
|
-
private queueFrame(session: Session, wire: Uint8Array, digest: string, requestReply = false, current: () => boolean = () => true, presence = false, retained?: () => boolean, onSent?: () => void, afterInboundAck?: string): void {
|
|
460
|
+
private queueFrame(session: Session, wire: Uint8Array, digest: string, requestReply = false, current: () => boolean = () => true, presence = false, retained?: () => boolean, onSent?: () => void, afterInboundAck?: string, codecFeatures = false, supported?: () => boolean, codecPending?: () => boolean, codecTarget?: { member: string; feature: RoomCodecFeature }): void {
|
|
386
461
|
this.current(session, undefined, this.stopping);
|
|
387
462
|
// Only an unsent observation may be replaced. Never reorder an attempted frame.
|
|
388
463
|
if (presence) {
|
|
389
|
-
const at = session.outbound.findIndex((frame, index) => frame !== session.closingHead && frame.presence && (!frame.requestReply || requestReply) && (index > 0 || !session.frameSent && !session.frameRetry));
|
|
464
|
+
const at = session.outbound.findIndex((frame, index) => frame !== session.closingHead && frame.presence && !!frame.codecFeatures === codecFeatures && (!frame.requestReply || requestReply) && (index > 0 || !session.frameSent && !session.frameRetry));
|
|
390
465
|
if (at >= 0) session.outboundBytes -= session.outbound.splice(at, 1)[0]!.wire.byteLength;
|
|
391
466
|
}
|
|
392
467
|
if (session.outbound.length >= LIMITS.packets || session.outboundBytes + wire.byteLength > LIMITS.queuedBytes) fail("limit");
|
|
393
|
-
session.outbound.push({ wire, digest, presence, requestReply, current, retained, onSent, afterInboundAck }); session.outboundBytes += wire.byteLength;
|
|
468
|
+
session.outbound.push({ wire, digest, presence, requestReply, current, retained, onSent, afterInboundAck, codecFeatures, supported, codecPending, codecTarget }); session.outboundBytes += wire.byteLength;
|
|
394
469
|
this.pumpFrames(session);
|
|
395
470
|
}
|
|
396
471
|
private pumpFrames(session: Session): void {
|
|
@@ -411,10 +486,22 @@ export class RoomLink {
|
|
|
411
486
|
void this.publishPresence().catch(() => this.retry(session));
|
|
412
487
|
return;
|
|
413
488
|
}
|
|
489
|
+
if (frame.supported && !frame.supported()) {
|
|
490
|
+
this.blockedCodec = frame.codecTarget;
|
|
491
|
+
this.setState(this.state, frame.codecPending?.() ? "codec_pending" : "codec_unsupported");
|
|
492
|
+
if (frame.retained) {
|
|
493
|
+
// The durable intent owns retry. Withdraw only this unsent ciphertext so
|
|
494
|
+
// later receipts can progress; a fresh codec observation reseals its retry.
|
|
495
|
+
session.outbound.shift(); session.outboundBytes -= frame.wire.byteLength; session.retryAttempt = 0;
|
|
496
|
+
this.pumpFrames(session);
|
|
497
|
+
}
|
|
498
|
+
return;
|
|
499
|
+
}
|
|
414
500
|
// A foreground receipt follows the chat ACK on the socket. Otherwise broker
|
|
415
501
|
// verification/storage of the receipt can pin the chat's one-packet slot.
|
|
416
502
|
if (frame.afterInboundAck && session.processingFrame === frame.afterInboundAck && session.acknowledgedFrame !== frame.afterInboundAck) return;
|
|
417
503
|
this.send(session, frame.wire); session.frameSent = true;
|
|
504
|
+
if (frame.codecTarget) this.refreshCodecCause(frame.codecTarget.member);
|
|
418
505
|
const onSent = frame.onSent; frame.onSent = undefined; onSent?.();
|
|
419
506
|
}
|
|
420
507
|
private storedFrame(session: Session, digest: string): void {
|
|
@@ -424,12 +511,14 @@ export class RoomLink {
|
|
|
424
511
|
session.frameSent = false; session.retryAttempt = 0;
|
|
425
512
|
if (session.frameRetry) clearTimeout(session.frameRetry);
|
|
426
513
|
session.frameRetry = undefined;
|
|
514
|
+
if (this.cause === "backpressure") this.setState(this.state);
|
|
427
515
|
if (this.stoppedFrame?.digest === digest) this.stoppedFrame.resolve();
|
|
428
516
|
this.pumpFrames(session);
|
|
429
517
|
}
|
|
430
518
|
private backoffFrame(session: Session, digest: string): void {
|
|
431
519
|
if (session.outbound[0]?.digest !== digest || session.frameRetry) return;
|
|
432
520
|
session.frameSent = false;
|
|
521
|
+
this.setState(this.state, "backpressure");
|
|
433
522
|
const delay = Math.min(30_000, 250 * 2 ** Math.min(session.retryAttempt++, 7));
|
|
434
523
|
session.frameRetry = setTimeout(() => {
|
|
435
524
|
session.frameRetry = undefined;
|
|
@@ -466,7 +555,7 @@ export class RoomLink {
|
|
|
466
555
|
if (session.processing || this.session !== session || !session.queue.length) return;
|
|
467
556
|
const bytes = session.queue.shift()!; session.queuedBytes -= bytes.length;
|
|
468
557
|
session.processing = true;
|
|
469
|
-
void this.handle(session, bytes).catch(
|
|
558
|
+
void this.handle(session, bytes).catch(error => this.retry(session, error)).finally(() => {
|
|
470
559
|
session.processing = false;
|
|
471
560
|
this.drain(session);
|
|
472
561
|
});
|
|
@@ -506,8 +595,8 @@ export class RoomLink {
|
|
|
506
595
|
return;
|
|
507
596
|
}
|
|
508
597
|
if (message.type === "ready") {
|
|
509
|
-
const ready = exact(message, ["v", "type", "room", "member", "epoch", "digest", ...(Object.hasOwn(message, "ephemeral") ? ["ephemeral"] : [])]), roster = session.roster;
|
|
510
|
-
if (ready.ephemeral !== undefined && ready.ephemeral !== true) fail();
|
|
598
|
+
const ready = exact(message, ["v", "type", "room", "member", "epoch", "digest", ...(Object.hasOwn(message, "ephemeral") ? ["ephemeral"] : []), ...(Object.hasOwn(message, "codecFeatures") ? ["codecFeatures", "codecSession"] : [])]), roster = session.roster;
|
|
599
|
+
if (ready.ephemeral !== undefined && ready.ephemeral !== true || ready.codecFeatures !== undefined && (ready.codecFeatures !== true || ready.ephemeral !== true || typeof ready.codecSession !== "string" || !DIGEST.test(ready.codecSession))) fail();
|
|
511
600
|
if (!roster || ready.room !== local.current.room || ready.member !== this.options.store.identity.public.fingerprint || ready.epoch !== roster.epoch || ready.digest !== roster.digest) fail("untrusted");
|
|
512
601
|
if (roster.digest !== local.current.digest) {
|
|
513
602
|
if (roster.owner !== this.options.store.identity.public.fingerprint || BigInt(roster.epoch) >= BigInt(local.current.epoch)) fail("untrusted");
|
|
@@ -515,17 +604,20 @@ export class RoomLink {
|
|
|
515
604
|
this.send(session, local.wire); return;
|
|
516
605
|
}
|
|
517
606
|
if (session.presenceRoster !== roster.digest) {
|
|
518
|
-
|
|
607
|
+
if (this.blockedCodec) this.setState(this.state);
|
|
608
|
+
session.presenceRoster = roster.digest; session.features = new Map(); session.codecPeers = new Map(); session.pendingPresence = new Map(); session.pendingPresenceReplies.clear(); session.presenceAsked = false;
|
|
519
609
|
session.requestPresenceReply = true; session.lastPresence = undefined;
|
|
520
610
|
}
|
|
521
611
|
session.ephemeral = ready.ephemeral === true;
|
|
612
|
+
session.codecFeatures = ready.codecFeatures === true;
|
|
613
|
+
session.codecSession = typeof ready.codecSession === "string" ? ready.codecSession : undefined;
|
|
522
614
|
session.phase = "ready"; session.ownerUpgrading = false; clearTimeout(session.handshake); this.attempts = 0;
|
|
523
615
|
if (session.deferredFrame) {
|
|
524
616
|
session.queue.unshift(session.deferredFrame); session.queuedBytes += session.deferredFrame.byteLength; session.deferredFrame = undefined;
|
|
525
617
|
}
|
|
526
618
|
if (!this.running || this.stopping) return;
|
|
527
|
-
this.armPresenceRefresh(session);
|
|
528
619
|
this.setState("online");
|
|
620
|
+
this.armPresenceRefresh(session);
|
|
529
621
|
const context = this.context(session, local.current.digest);
|
|
530
622
|
this.options.application?.members(context);
|
|
531
623
|
this.restoreReceipts(session);
|
|
@@ -536,6 +628,33 @@ export class RoomLink {
|
|
|
536
628
|
void this.publishPresence().catch(() => this.retry(session));
|
|
537
629
|
return;
|
|
538
630
|
}
|
|
631
|
+
if (message.type === "codec_peers") {
|
|
632
|
+
const inventory = exact(message, ["v", "type", "peers", "digest"]);
|
|
633
|
+
if (!Array.isArray(inventory.peers) || inventory.peers.length > 64 || typeof inventory.digest !== "string" || !DIGEST.test(inventory.digest)) fail("untrusted");
|
|
634
|
+
if (inventory.digest !== local.current.digest) return; // A previous-roster observation cannot revoke or grant current support.
|
|
635
|
+
if (!session.codecFeatures || session.phase !== "ready") fail("untrusted");
|
|
636
|
+
const members = roomMembers(local.current), seen = new Set<string>();
|
|
637
|
+
const peers = inventory.peers.map(value => {
|
|
638
|
+
const peer = exact(value, ["member", "session", "supported"]);
|
|
639
|
+
if (typeof peer.member !== "string" || !members.some(member => member.fingerprint === peer.member) || seen.has(peer.member)
|
|
640
|
+
|| peer.member === this.options.store.identity.public.fingerprint || typeof peer.supported !== "boolean"
|
|
641
|
+
|| peer.session !== null && (typeof peer.session !== "string" || !DIGEST.test(peer.session)) || peer.session === null && peer.supported) fail("untrusted");
|
|
642
|
+
seen.add(peer.member);
|
|
643
|
+
return { member: peer.member, session: peer.session as string | null, supported: peer.supported };
|
|
644
|
+
});
|
|
645
|
+
let advertise = false;
|
|
646
|
+
for (const peer of peers) {
|
|
647
|
+
const previous = session.codecPeers?.get(peer.member);
|
|
648
|
+
if (previous?.session === peer.session && previous.supported === peer.supported) continue;
|
|
649
|
+
(session.codecPeers ??= new Map()).set(peer.member, { session: peer.session, supported: peer.supported });
|
|
650
|
+
session.features?.delete(peer.member); session.pendingPresence?.delete(peer.member);
|
|
651
|
+
if (peer.supported && peer.session !== null) advertise = true;
|
|
652
|
+
this.refreshCodecCause(peer.member);
|
|
653
|
+
try { this.options.application?.codecsChanged?.(this.context(session, local.current.digest), { member: peer.member }); } catch { /* Compatibility evidence grants no authority. */ }
|
|
654
|
+
}
|
|
655
|
+
if (advertise) void this.publishCodecPresence(session).catch(error => this.retry(session, error));
|
|
656
|
+
return;
|
|
657
|
+
}
|
|
539
658
|
if (message.type === "frame") {
|
|
540
659
|
if (session.phase !== "ready") {
|
|
541
660
|
// X2 may already have one ciphertext in flight while this verified owner
|
|
@@ -587,6 +706,7 @@ export class RoomLink {
|
|
|
587
706
|
// X2 declares these refusal controls nonterminal. Their unsigned control
|
|
588
707
|
// cannot settle any application; its local ACK deadline records uncertainty.
|
|
589
708
|
if (error.code === "replay_refused" || error.code === "unexpected_ack") return;
|
|
709
|
+
this.setState(this.state, "broker_refused");
|
|
590
710
|
fail("unavailable");
|
|
591
711
|
}
|
|
592
712
|
fail();
|
|
@@ -615,9 +735,15 @@ export class RoomLink {
|
|
|
615
735
|
if (!sender) return { status: "trust_changed" };
|
|
616
736
|
let validPresence = false;
|
|
617
737
|
try {
|
|
618
|
-
if (packet(input.plaintext).type === "presence") {
|
|
738
|
+
if (packet(input.plaintext).type === "presence") {
|
|
739
|
+
presence(input.plaintext, sender.exposedPeers);
|
|
740
|
+
const features = packet(input.plaintext).features;
|
|
741
|
+
if (frame.codecFeatures === true ? !session.codecFeatures || features === undefined : features !== undefined) fail();
|
|
742
|
+
validPresence = true;
|
|
743
|
+
}
|
|
619
744
|
else {
|
|
620
745
|
invalidApplication = true;
|
|
746
|
+
if (frame.codecFeatures) fail("untrusted");
|
|
621
747
|
if (!this.options.application) fail("untrusted");
|
|
622
748
|
application = decodeRoomMessage(input.plaintext);
|
|
623
749
|
if (application.type === "chat" && application.envelope.from !== "user" && !sender.exposedPeers.includes(application.envelope.from)) fail("untrusted");
|
|
@@ -666,7 +792,6 @@ export class RoomLink {
|
|
|
666
792
|
this.options.store.settleInbox(this.options.name, acceptedDigest);
|
|
667
793
|
if (result?.status === "accepted") this.capacityChanged(session, digest);
|
|
668
794
|
} else if (result?.status === "accepted") {
|
|
669
|
-
if (result.gap !== "0") try { this.options.application?.gap?.(this.context(session, digest), { sender: frame.sender, count: result.gap }); } catch { /* metadata observation cannot affect admission */ }
|
|
670
795
|
const sender = roomMembers(latest.current).find(member => member.fingerprint === frame.sender);
|
|
671
796
|
if (!sender) fail("untrusted");
|
|
672
797
|
if (application && this.options.application) {
|
|
@@ -674,9 +799,35 @@ export class RoomLink {
|
|
|
674
799
|
} else {
|
|
675
800
|
const peers = presence(result.plaintext, sender.exposedPeers);
|
|
676
801
|
this.current(session, digest);
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
802
|
+
if (frame.codecFeatures === true) {
|
|
803
|
+
const payload = packet(result.plaintext), peer = session.codecPeers?.get(frame.sender);
|
|
804
|
+
if (frame.epoch === latest.current.epoch && peer?.supported && peer.session !== null && payload.codecSession === peer.session) {
|
|
805
|
+
const features = roomCodecFeatures(payload.features), previous = session.features?.get(frame.sender);
|
|
806
|
+
const changed = ROOM_CODEC_FEATURES.filter(feature => previous === undefined || previous.session !== peer.session || previous.features.includes(feature) !== features.includes(feature));
|
|
807
|
+
if (!previous || previous.session !== peer.session || previous.features.length !== features.length || previous.features.some((feature, index) => feature !== features[index])) {
|
|
808
|
+
(session.features ??= new Map()).set(frame.sender, { session: peer.session, features });
|
|
809
|
+
}
|
|
810
|
+
this.refreshCodecCause(frame.sender);
|
|
811
|
+
const pending = session.pendingPresence?.get(frame.sender);
|
|
812
|
+
session.pendingPresence?.delete(frame.sender);
|
|
813
|
+
if (pending?.session === peer.session && pending.epoch === latest.current.epoch) {
|
|
814
|
+
try { this.options.onPresence?.({ member: frame.sender, peers: pending.peers, observedAt: pending.observedAt }); } catch { /* Observation only, with the original local receive time. */ }
|
|
815
|
+
}
|
|
816
|
+
this.pumpFrames(session);
|
|
817
|
+
if (changed.length) try { this.options.application?.codecsChanged?.(this.context(session, digest), { member: frame.sender, features: changed }); } catch { /* Compatibility observations cannot change admission. */ }
|
|
818
|
+
}
|
|
819
|
+
} else {
|
|
820
|
+
session.pendingPresenceReplies.delete(frame.sender);
|
|
821
|
+
if (session.presenceAsked && session.pendingPresenceReplies.size === 0) session.requestPresenceReply = false;
|
|
822
|
+
const peer = session.codecPeers?.get(frame.sender), advertisement = session.features?.get(frame.sender);
|
|
823
|
+
const knownLegacy = peer !== undefined && peer.session !== null && peer.supported === false;
|
|
824
|
+
if (session.codecFeatures && peers.some(peer => peer.state !== "offline") && !knownLegacy && (!peer?.session || advertisement?.session !== peer.session)) {
|
|
825
|
+
(session.pendingPresence ??= new Map()).set(frame.sender, { session: peer?.session ?? null, epoch: frame.epoch, peers, observedAt: this.observationTime() ?? null });
|
|
826
|
+
} else {
|
|
827
|
+
session.pendingPresence?.delete(frame.sender); // A newer negative or admitted observation supersedes any deferred live state.
|
|
828
|
+
try { this.options.onPresence?.({ member: frame.sender, peers }); } catch { /* validated observation only */ }
|
|
829
|
+
}
|
|
830
|
+
}
|
|
680
831
|
this.current(session, digest);
|
|
681
832
|
this.options.store.settleInbox(this.options.name, acceptedDigest);
|
|
682
833
|
this.capacityChanged(session, digest);
|
|
@@ -749,9 +900,21 @@ export class RoomLink {
|
|
|
749
900
|
const session = this.session;
|
|
750
901
|
if (this.stopping || !this.running || !session || session.phase !== "ready") { if (metadata) throw new RoomNotSentError(); fail("unavailable"); }
|
|
751
902
|
if (this.applicationPauses.size) { if (metadata) throw new RoomNotSentError(); fail("state_changed"); }
|
|
903
|
+
const required = roomRequiredCodecFeature(message);
|
|
904
|
+
if (required && !this.codecSupported(recipient, required)) throw this.codecRefusal(recipient, required);
|
|
752
905
|
const digest = this.current(session).current.digest;
|
|
753
906
|
const allowed = () => this.publicationCurrent(session, digest) && current();
|
|
754
|
-
const
|
|
907
|
+
const supported = () => required === undefined || this.codecSupported(recipient, required);
|
|
908
|
+
let codecLost = false;
|
|
909
|
+
const admitted = () => {
|
|
910
|
+
if (!supported()) { codecLost = true; return false; }
|
|
911
|
+
return this.applicationPauses.size === 0 && allowed();
|
|
912
|
+
};
|
|
913
|
+
const assertAdmitted = () => {
|
|
914
|
+
if (admitted()) return;
|
|
915
|
+
if (codecLost && required) throw this.codecRefusal(recipient, required);
|
|
916
|
+
fail("state_changed");
|
|
917
|
+
};
|
|
755
918
|
let queueStarted = false;
|
|
756
919
|
try {
|
|
757
920
|
let presenceWork: Promise<void> | undefined;
|
|
@@ -767,7 +930,7 @@ export class RoomLink {
|
|
|
767
930
|
// Reserve this chat's FIFO slot before yielding to the earlier observation.
|
|
768
931
|
return await this.serialSend(async () => {
|
|
769
932
|
if (presenceWork) await presenceWork;
|
|
770
|
-
|
|
933
|
+
assertAdmitted();
|
|
771
934
|
if (metadata && (session.outbound.length >= LIMITS.packets || session.outboundBytes + plaintext.byteLength > LIMITS.queuedBytes
|
|
772
935
|
|| session.ws.bufferedAmount > LIMITS.backpressure)) throw new RoomNotSentError();
|
|
773
936
|
if (message.type === "receipt") {
|
|
@@ -781,13 +944,21 @@ export class RoomLink {
|
|
|
781
944
|
const row = this.current(session, digest);
|
|
782
945
|
if (!roomMembers(row.current).some(member => member.fingerprint === recipient) || recipient === this.options.store.identity.public.fingerprint) fail("untrusted");
|
|
783
946
|
let wire = await sealFrame({ epoch: row.current, identity: this.options.store.identity, recipients: [recipient], plaintext, sequence: this.options.store.sequence });
|
|
784
|
-
|
|
947
|
+
this.clockRecoveredAfterWrite();
|
|
948
|
+
assertAdmitted();
|
|
785
949
|
let frameDigest: string;
|
|
786
950
|
if (metadata) {
|
|
787
951
|
queueStarted = true;
|
|
788
952
|
let record: Awaited<ReturnType<RoomStore["queueApplication"]>>;
|
|
789
953
|
try { record = await this.options.store.queueApplication(this.options.name, wire, metadata, admitted); }
|
|
790
954
|
catch (error) {
|
|
955
|
+
if (codecLost && required && error instanceof RoomStoreError && error.code === "state_changed") {
|
|
956
|
+
// The atomic fence refused this attempt. Positively distinguish a
|
|
957
|
+
// never-admitted application from an earlier durable retry intent.
|
|
958
|
+
if (!this.options.store.applicationOutbox(this.options.name).some(row => row.deliveryId === metadata.deliveryId || row.application === metadata.application)) throw this.codecRefusal(recipient, required);
|
|
959
|
+
if (this.codecState(recipient, required) !== "unsupported") throw this.codecRefusal(recipient, required);
|
|
960
|
+
throw new RoomNotSentError();
|
|
961
|
+
}
|
|
791
962
|
// An explicit atomic refusal plus absent durable intent is positive not-sent evidence.
|
|
792
963
|
if (error instanceof RoomStoreError && ["limit", "invalid", "untrusted", "state_changed"].includes(error.code)
|
|
793
964
|
&& !this.options.store.applicationOutbox(this.options.name).some(row => row.deliveryId === metadata.deliveryId || row.application === metadata.application)) throw new RoomNotSentError();
|
|
@@ -806,7 +977,7 @@ export class RoomLink {
|
|
|
806
977
|
this.queueFrame(session, wire, frameDigest, false, allowed, false,
|
|
807
978
|
metadata ? () => this.options.store.applicationSendable(this.options.name, frameDigest)
|
|
808
979
|
: message.type === "receipt" ? () => this.options.store.receiptSendable(this.options.name, frameDigest) : undefined,
|
|
809
|
-
undefined, message.type === "receipt" && message.frame === session.processingFrame ? message.frame : undefined);
|
|
980
|
+
undefined, message.type === "receipt" && message.frame === session.processingFrame ? message.frame : undefined, false, required ? supported : undefined, required ? () => this.codecState(recipient, required) === "pending" : undefined, required ? { member: recipient, feature: required } : undefined);
|
|
810
981
|
} catch (error) {
|
|
811
982
|
// The durable outbox owns retry after local backpressure; this is no uncertainty.
|
|
812
983
|
if (!allowed() || !(error instanceof RoomLinkError) || error.code !== "limit" || !metadata && message.type !== "receipt") throw error;
|
|
@@ -814,10 +985,50 @@ export class RoomLink {
|
|
|
814
985
|
}
|
|
815
986
|
return frameDigest;
|
|
816
987
|
}); } catch (error) {
|
|
988
|
+
if (error instanceof RoomCodecUnsupportedError || error instanceof RoomCodecPendingError) throw error;
|
|
817
989
|
if (metadata && !queueStarted) throw new RoomNotSentError();
|
|
818
990
|
throw error;
|
|
819
991
|
}
|
|
820
992
|
}
|
|
993
|
+
/** A newly ready member may have missed our first advertisement. Repair it
|
|
994
|
+
* independently of legacy request/reply and snapshot dedupe, without a timer. */
|
|
995
|
+
private publishCodecPresence(session: Session): Promise<void> {
|
|
996
|
+
if (session.codecPublishing) { session.codecDirty = true; return session.codecPublishing; }
|
|
997
|
+
const work = (async () => {
|
|
998
|
+
do {
|
|
999
|
+
session.codecDirty = false;
|
|
1000
|
+
await this.serialSend(async () => {
|
|
1001
|
+
if (!this.running || this.stopping || this.session !== session || !session.codecFeatures || !session.codecSession || session.phase !== "ready") return;
|
|
1002
|
+
const row = this.current(session), identity = this.options.store.identity;
|
|
1003
|
+
if (!this.publicationCurrent(session, row.current.digest)) return;
|
|
1004
|
+
const own = roomMembers(row.current).find(member => member.fingerprint === identity.public.fingerprint);
|
|
1005
|
+
if (!own) fail("untrusted");
|
|
1006
|
+
const peers = this.currentPresence(own.exposedPeers), recipients = roomMembers(row.current).map(member => member.fingerprint).filter(member => member !== identity.public.fingerprint).sort();
|
|
1007
|
+
if (!recipients.length) return;
|
|
1008
|
+
const wire = await sealFrame({ epoch: row.current, identity, recipients,
|
|
1009
|
+
plaintext: canonicalBytes({ v: 1, type: "presence", peers, features: ROOM_CODEC_FEATURES, codecSession: session.codecSession }),
|
|
1010
|
+
ephemeral: true, codecFeatures: true, sequence: this.options.store.sequence });
|
|
1011
|
+
if (!this.publicationCurrent(session, row.current.digest)) return;
|
|
1012
|
+
const digest = await this.frameDigest(wire);
|
|
1013
|
+
this.queueFrame(session, wire, digest, false,
|
|
1014
|
+
() => this.publicationCurrent(session, row.current.digest) && peers.every(({ id }) => this.currentPresence(own.exposedPeers).some(value => value.id === id)),
|
|
1015
|
+
true, undefined, undefined, undefined, true);
|
|
1016
|
+
});
|
|
1017
|
+
} while (session.codecDirty && this.session === session && this.running && !this.stopping);
|
|
1018
|
+
})();
|
|
1019
|
+
session.codecPublishing = work;
|
|
1020
|
+
void work.finally(() => {
|
|
1021
|
+
if (session.codecPublishing !== work) return;
|
|
1022
|
+
session.codecPublishing = undefined;
|
|
1023
|
+
const again = session.codecDirty; session.codecDirty = false;
|
|
1024
|
+
// A peer event can arrive after the loop resolved but before this cleanup.
|
|
1025
|
+
// Consume that real event once, after releasing the old publication slot.
|
|
1026
|
+
if (again && this.session === session && this.running && !this.stopping && session.phase === "ready") {
|
|
1027
|
+
void this.publishCodecPresence(session).catch(error => this.retry(session, error));
|
|
1028
|
+
}
|
|
1029
|
+
}).catch(() => undefined);
|
|
1030
|
+
return work;
|
|
1031
|
+
}
|
|
821
1032
|
async publishPresence(): Promise<void> {
|
|
822
1033
|
const session = this.session;
|
|
823
1034
|
if (!this.running || this.stopping || !session || session.phase !== "ready") return;
|
|
@@ -846,7 +1057,20 @@ export class RoomLink {
|
|
|
846
1057
|
const requestReply = !response && session.requestPresenceReply;
|
|
847
1058
|
const presenceKey = JSON.stringify([row.current.digest, recipients, peers, requestReply]);
|
|
848
1059
|
if (!response && !forceRequest && session.lastPresence === presenceKey) return;
|
|
1060
|
+
// Positive codec evidence precedes the peer-state observation on this
|
|
1061
|
+
// FIFO lane. Observed idle never races its own pending advertisement.
|
|
1062
|
+
if (session.codecFeatures && session.codecSession) {
|
|
1063
|
+
const featureWire = await sealFrame({ epoch: row.current, identity, recipients,
|
|
1064
|
+
plaintext: canonicalBytes({ v: 1, type: "presence", peers, features: ROOM_CODEC_FEATURES, codecSession: session.codecSession }),
|
|
1065
|
+
ephemeral: true, codecFeatures: true, sequence: this.options.store.sequence });
|
|
1066
|
+
if (!this.publicationCurrent(session, row.current.digest)) return;
|
|
1067
|
+
const featureDigest = await this.frameDigest(featureWire);
|
|
1068
|
+
this.queueFrame(session, featureWire, featureDigest, false,
|
|
1069
|
+
() => this.publicationCurrent(session, row.current.digest) && peers.every(({ id }) => this.currentPresence(own.exposedPeers).some(value => value.id === id)),
|
|
1070
|
+
true, undefined, undefined, undefined, true);
|
|
1071
|
+
}
|
|
849
1072
|
const wire = await sealFrame({ epoch: row.current, identity, recipients, plaintext: canonicalBytes({ v: 1, type: "presence", peers, ...(requestReply ? { requestReply: true } : {}) }), ...(session.ephemeral ? { ephemeral: true as const } : {}), sequence: this.options.store.sequence });
|
|
1073
|
+
this.clockRecoveredAfterWrite();
|
|
850
1074
|
if (!this.publicationCurrent(session, row.current.digest)) return;
|
|
851
1075
|
// Machine-local exposure can change during reserve/encryption/signing even
|
|
852
1076
|
// when the signed roster is unchanged. Never emit an id it now withholds.
|
|
@@ -864,7 +1088,7 @@ export class RoomLink {
|
|
|
864
1088
|
session.lastPresence = presenceKey; sent = true;
|
|
865
1089
|
});
|
|
866
1090
|
} catch (error) {
|
|
867
|
-
this.retry(session); // Existing bounded reconnect, never a self-polling refresh retry.
|
|
1091
|
+
this.retry(session, error); // Existing bounded reconnect, never a self-polling refresh retry.
|
|
868
1092
|
throw error;
|
|
869
1093
|
} finally {
|
|
870
1094
|
this.publishing = false;
|