fix(collab): chunked welcome so large session snapshots can join
The host used to ship the entire transcript inside a single welcome frame, so a multi-MB session spent the guest's 30s first-welcome timeout on the relay transfer itself: ~1.3 MB took ~3s, ~4.2 MB took ~12s, and ~13.6 MB never arrived before the guest gave up with 'timed out waiting for the host's welcome'. Bump COLLAB_PROTO to 2 and split the welcome: - welcome carries metadata only (header, state, agents, entryCount, readOnly) and lands in well under one second. - a train of snapshot-chunk frames (SNAPSHOT_CHUNK_BYTES = 512 KB, oversize entries ship alone) carries the transcript. Last chunk flips final: true; an empty snapshot still emits one final chunk. - the host queues welcome + chunks synchronously inside #handleHello, preserving the host comment's ordering invariant (later broadcast frames cannot interleave between them). - the TUI guest accumulates chunks under a SNAPSHOT_PROGRESS_TIMEOUT_MS that resets per chunk; only after final does it write the replica jsonl, switchSession, and render. The first-welcome timeout still guards arrival of the small welcome. - the collab-web GuestClient streams entries into the snapshot as chunks arrive and flips phase to 'live' on final. Includes a contract test (in-process relay) asserting the welcome is metadata-only, the chunk train fans the 1.5 MB synthetic transcript across multiple frames with only the last marked final, and the flattened entries match the source snapshot. Fixes #3144
This commit is contained in:
@@ -2,6 +2,10 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed `/join` failing with `timed out waiting for the host's welcome` on collab sessions whose existing transcript was more than a few MB. The host now sends a small `welcome` frame (header + state + agents + `entryCount`) followed by a train of `snapshot-chunk` frames (`SNAPSHOT_CHUNK_BYTES = 512 KB`), and the guest accumulates them under a per-chunk progress timeout that resets on each chunk arrival. The first welcome lands well under one second on the default relay, so the guest's 30s first-welcome budget is no longer spent transferring the snapshot. Requires the new `COLLAB_PROTO = 2` on both sides; older hosts/guests are rejected with the existing protocol-mismatch error. ([#3144](https://github.com/can1357/oh-my-pi/issues/3144))
|
||||
|
||||
## [16.1.7] - 2026-06-20
|
||||
|
||||
### Fixed
|
||||
|
||||
@@ -20,6 +20,7 @@ import type { AgentHubRemote } from "../modes/components/agent-hub";
|
||||
import type { InteractiveModeContext } from "../modes/types";
|
||||
import { AgentRegistry } from "../registry/agent-registry";
|
||||
import type { AgentSessionEvent } from "../session/agent-session";
|
||||
import type { SessionEntry } from "../session/session-entries";
|
||||
import { shouldDisableReasoning, toReasoningEffort } from "../thinking";
|
||||
import { setSessionTerminalTitle } from "../utils/title-generator";
|
||||
import { importRoomKey } from "./crypto";
|
||||
@@ -47,10 +48,35 @@ export const COLLAB_GUEST_ALLOWED_COMMANDS: Record<string, true> = {
|
||||
exit: true,
|
||||
quit: true,
|
||||
};
|
||||
/**
|
||||
* How long the guest waits for the host's small `welcome` frame before giving
|
||||
* up on the join. The welcome carries metadata only (`entryCount`, header,
|
||||
* state, agents), so it lands well under one second on any working relay.
|
||||
*/
|
||||
const WELCOME_TIMEOUT_MS = 30_000;
|
||||
/**
|
||||
* How long the guest waits between `snapshot-chunk` frames during the initial
|
||||
* sync. Resets on each chunk arrival, so a multi-MB snapshot only fails when
|
||||
* the relay genuinely stalls — not because the total wall-clock crossed the
|
||||
* welcome budget. The default relay sustains ~350 KB/s; a 512 KB chunk lands
|
||||
* in under two seconds with comfortable headroom.
|
||||
*/
|
||||
const SNAPSHOT_PROGRESS_TIMEOUT_MS = 30_000;
|
||||
const TRANSCRIPT_TIMEOUT_MS = 20_000;
|
||||
|
||||
type WelcomeFrame = Extract<CollabFrame, { t: "welcome" }>;
|
||||
type SnapshotChunkFrame = Extract<CollabFrame, { t: "snapshot-chunk" }>;
|
||||
|
||||
/** Accumulator for an in-flight chunked welcome — see {@link CollabGuestLink}. */
|
||||
interface PendingSnapshot {
|
||||
header: WelcomeFrame["header"];
|
||||
state: WelcomeFrame["state"];
|
||||
agents: AgentSnapshot[];
|
||||
readOnly: boolean;
|
||||
entryCount: number;
|
||||
entries: SessionEntry[];
|
||||
isResync: boolean;
|
||||
}
|
||||
|
||||
export class CollabGuestLink {
|
||||
#ctx: InteractiveModeContext;
|
||||
@@ -60,8 +86,24 @@ export class CollabGuestLink {
|
||||
#returnSessionFile: string | null = null;
|
||||
/** Frames apply strictly in arrival order through this chain. */
|
||||
#applyChain: Promise<void> = Promise.resolve();
|
||||
/** True after the initial snapshot has been written to disk and resumed. */
|
||||
#welcomed = false;
|
||||
#left = false;
|
||||
/**
|
||||
* Buffer for the in-flight chunked welcome. Set by the small `welcome`
|
||||
* frame, accumulated by every `snapshot-chunk`, drained when the final
|
||||
* chunk lands (or the snapshot-progress timer fires).
|
||||
*/
|
||||
#pendingSnapshot: PendingSnapshot | null = null;
|
||||
/**
|
||||
* Fires `firstWelcome.reject` from a stalled welcome/snapshot during the
|
||||
* initial join. Set in {@link join}, cleared on resolve/reject; arming a
|
||||
* timer after that point is a no-op so reconnect-time stalls fall through
|
||||
* to the normal socket close handling instead of aborting the live session.
|
||||
*/
|
||||
#joinReject: ((err: Error) => void) | null = null;
|
||||
#welcomeTimer: Timer | null = null;
|
||||
#snapshotProgressTimer: Timer | null = null;
|
||||
/** base64url write token from a full link; absent when joined via a view link. */
|
||||
#writeToken: string | undefined;
|
||||
/** True when the host marked this peer read-only (view link). */
|
||||
@@ -143,11 +185,23 @@ export class CollabGuestLink {
|
||||
|
||||
const firstWelcome = Promise.withResolvers<void>();
|
||||
let joined = false;
|
||||
this.#joinReject = err => firstWelcome.reject(err);
|
||||
|
||||
const finishJoin = (): void => {
|
||||
if (joined) return;
|
||||
joined = true;
|
||||
firstWelcome.resolve();
|
||||
};
|
||||
|
||||
socket.onOpen = () => {
|
||||
// (Re)connect: re-introduce ourselves; the host answers with a fresh
|
||||
// welcome which (re)syncs the replica.
|
||||
// welcome which (re)syncs the replica. Discard any partially-streamed
|
||||
// snapshot from a prior connection: the host will resend the full
|
||||
// chunk train.
|
||||
this.#welcomed = false;
|
||||
this.#pendingSnapshot = null;
|
||||
this.#clearSnapshotProgressTimer();
|
||||
this.#armWelcomeTimer();
|
||||
socket.send({
|
||||
t: "hello",
|
||||
proto: COLLAB_PROTO,
|
||||
@@ -159,10 +213,19 @@ export class CollabGuestLink {
|
||||
this.#applyChain = this.#applyChain
|
||||
.then(async () => {
|
||||
if (frame.t === "welcome") {
|
||||
await this.#applyWelcome(frame, joined);
|
||||
if (!joined) {
|
||||
joined = true;
|
||||
firstWelcome.resolve();
|
||||
this.#clearWelcomeTimer();
|
||||
this.#beginWelcome(frame, joined);
|
||||
if (frame.entryCount === 0) {
|
||||
await this.#finalizeSnapshot();
|
||||
finishJoin();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (frame.t === "snapshot-chunk") {
|
||||
const ready = this.#accumulateSnapshotChunk(frame);
|
||||
if (ready) {
|
||||
await this.#finalizeSnapshot();
|
||||
finishJoin();
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -172,6 +235,8 @@ export class CollabGuestLink {
|
||||
.catch(err => logger.warn("collab guest frame apply failed", { type: frame.t, error: String(err) }));
|
||||
};
|
||||
socket.onClose = (reason, willReconnect) => {
|
||||
this.#clearWelcomeTimer();
|
||||
this.#clearSnapshotProgressTimer();
|
||||
this.#flushPendingTranscripts();
|
||||
if (this.#left) return;
|
||||
if (!joined) {
|
||||
@@ -187,10 +252,6 @@ export class CollabGuestLink {
|
||||
};
|
||||
socket.connect();
|
||||
|
||||
const timeout = setTimeout(
|
||||
() => firstWelcome.reject(new Error("timed out waiting for the host's welcome")),
|
||||
WELCOME_TIMEOUT_MS,
|
||||
);
|
||||
try {
|
||||
await firstWelcome.promise;
|
||||
} catch (err) {
|
||||
@@ -199,7 +260,9 @@ export class CollabGuestLink {
|
||||
this.#socket = null;
|
||||
throw err;
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
this.#joinReject = null;
|
||||
this.#clearWelcomeTimer();
|
||||
this.#clearSnapshotProgressTimer();
|
||||
}
|
||||
|
||||
this.#ctx.collabGuest = this;
|
||||
@@ -222,11 +285,56 @@ export class CollabGuestLink {
|
||||
this.#socket?.send({ t: "abort" });
|
||||
}
|
||||
|
||||
/** Write the welcome snapshot to the replica file and (re)load it through the resume machinery. */
|
||||
async #applyWelcome(frame: WelcomeFrame, isResync: boolean): Promise<void> {
|
||||
/**
|
||||
* Latch the welcome metadata and prime the snapshot accumulator. The
|
||||
* heavy resume work (file write, `switchSession`, render) only happens in
|
||||
* {@link #finalizeSnapshot}, so the small welcome frame clears the join
|
||||
* timeout immediately even when the transcript still has to stream in.
|
||||
*/
|
||||
#beginWelcome(frame: WelcomeFrame, isResync: boolean): void {
|
||||
if (this.#left) return;
|
||||
this.#pendingSnapshot = {
|
||||
header: frame.header,
|
||||
state: frame.state,
|
||||
agents: frame.agents,
|
||||
readOnly: frame.readOnly === true,
|
||||
entryCount: frame.entryCount,
|
||||
entries: [],
|
||||
isResync,
|
||||
};
|
||||
this.#armSnapshotProgressTimer();
|
||||
}
|
||||
|
||||
/**
|
||||
* Append a chunk to the pending snapshot. Returns `true` when the
|
||||
* accumulator has gathered every entry the welcome promised, or the host
|
||||
* tagged this chunk as `final`. The caller is responsible for invoking
|
||||
* {@link #finalizeSnapshot} on the same applyChain microtask.
|
||||
*/
|
||||
#accumulateSnapshotChunk(frame: SnapshotChunkFrame): boolean {
|
||||
const pending = this.#pendingSnapshot;
|
||||
if (!pending) {
|
||||
logger.debug("collab guest dropping orphan snapshot-chunk");
|
||||
return false;
|
||||
}
|
||||
pending.entries.push(...frame.entries);
|
||||
const complete = frame.final || pending.entries.length >= pending.entryCount;
|
||||
if (complete) {
|
||||
this.#clearSnapshotProgressTimer();
|
||||
} else {
|
||||
this.#armSnapshotProgressTimer();
|
||||
}
|
||||
return complete;
|
||||
}
|
||||
|
||||
/** Write the accumulated welcome snapshot to the replica file and (re)load it through the resume machinery. */
|
||||
async #finalizeSnapshot(): Promise<void> {
|
||||
const pending = this.#pendingSnapshot;
|
||||
this.#pendingSnapshot = null;
|
||||
this.#clearSnapshotProgressTimer();
|
||||
if (!pending || this.#left) return;
|
||||
const replicaPath = path.join(getConfigRootDir(), "collab", `${this.#roomId}.jsonl`);
|
||||
const lines = [frame.header, ...frame.entries].map(entry => JSON.stringify(entry)).join("\n");
|
||||
const lines = [pending.header, ...pending.entries].map(entry => JSON.stringify(entry)).join("\n");
|
||||
await Bun.write(replicaPath, `${lines}\n`);
|
||||
|
||||
// Resume sequence (selector-controller.handleResumeSession) minus
|
||||
@@ -235,20 +343,54 @@ export class CollabGuestLink {
|
||||
this.#clearTransientUi();
|
||||
this.#clearAgentMirror();
|
||||
await this.#ctx.session.switchSession(replicaPath);
|
||||
this.state = frame.state;
|
||||
this.#applyHostState(frame.state);
|
||||
this.state = pending.state;
|
||||
this.#applyHostState(pending.state);
|
||||
this.#ctx.resetObserverRegistry();
|
||||
this.#applyAgentSnapshots(frame.agents);
|
||||
this.#applyAgentSnapshots(pending.agents);
|
||||
this.#assistantStreamSynced = false;
|
||||
setSessionTerminalTitle(frame.state.sessionName ?? frame.header.title, frame.state.cwd);
|
||||
setSessionTerminalTitle(pending.state.sessionName ?? pending.header.title, pending.state.cwd);
|
||||
this.#ctx.chatContainer.clear();
|
||||
this.#ctx.renderInitialMessages({ clearTerminalHistory: true });
|
||||
await this.#ctx.reloadTodos();
|
||||
this.#updateStatusSegment();
|
||||
this.#readOnly = frame.readOnly === true;
|
||||
this.#readOnly = pending.readOnly;
|
||||
this.#welcomed = true;
|
||||
const suffix = this.#readOnly ? " (read-only)" : "";
|
||||
this.#ctx.showStatus(isResync ? `Reconnected to collab session${suffix}` : `Joined collab session${suffix}`);
|
||||
this.#ctx.showStatus(
|
||||
pending.isResync ? `Reconnected to collab session${suffix}` : `Joined collab session${suffix}`,
|
||||
);
|
||||
}
|
||||
|
||||
#armWelcomeTimer(): void {
|
||||
if (this.#joinReject === null) return;
|
||||
this.#clearWelcomeTimer();
|
||||
this.#welcomeTimer = setTimeout(() => {
|
||||
this.#welcomeTimer = null;
|
||||
this.#joinReject?.(new Error("timed out waiting for the host's welcome"));
|
||||
}, WELCOME_TIMEOUT_MS);
|
||||
}
|
||||
|
||||
#clearWelcomeTimer(): void {
|
||||
if (this.#welcomeTimer !== null) {
|
||||
clearTimeout(this.#welcomeTimer);
|
||||
this.#welcomeTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
#armSnapshotProgressTimer(): void {
|
||||
if (this.#joinReject === null) return;
|
||||
this.#clearSnapshotProgressTimer();
|
||||
this.#snapshotProgressTimer = setTimeout(() => {
|
||||
this.#snapshotProgressTimer = null;
|
||||
this.#joinReject?.(new Error("timed out waiting for the host's session snapshot"));
|
||||
}, SNAPSHOT_PROGRESS_TIMEOUT_MS);
|
||||
}
|
||||
|
||||
#clearSnapshotProgressTimer(): void {
|
||||
if (this.#snapshotProgressTimer !== null) {
|
||||
clearTimeout(this.#snapshotProgressTimer);
|
||||
this.#snapshotProgressTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
#applyFrame(frame: CollabFrame): void {
|
||||
|
||||
@@ -94,6 +94,13 @@ function isWireSessionEntry(entry: StoredSessionEntry): entry is StoredSessionEn
|
||||
const CONNECT_TIMEOUT_MS = 15_000;
|
||||
/** Max bytes served per fetch-transcript reply (guest re-requests from `newSize`). */
|
||||
const TRANSCRIPT_READ_CAP = 4 * 1024 * 1024;
|
||||
/**
|
||||
* Soft byte cap per `snapshot-chunk` frame. The first MB of a snapshot takes
|
||||
* ~3s through the default relay, so a 512 KB chunk lands well under the
|
||||
* guest's 30 s per-chunk progress timeout; oversized single entries still
|
||||
* ship in a chunk of their own.
|
||||
*/
|
||||
const SNAPSHOT_CHUNK_BYTES = 512 * 1024;
|
||||
|
||||
/** Display name for this process's user in collab sessions. */
|
||||
export function collabDisplayName(ctx: InteractiveModeContext): string {
|
||||
@@ -323,9 +330,10 @@ export class CollabHost {
|
||||
const canWrite = this.#verifyWriteToken(writeToken);
|
||||
this.#peers.set(fromPeer, { name: cleanName, canWrite });
|
||||
|
||||
// Snapshot and send synchronously: no awaits between snapshot and send, so
|
||||
// later entries/events queue behind the welcome on the same socket and the
|
||||
// guest never sees a gap.
|
||||
// Snapshot and send synchronously: no awaits between snapshot, welcome,
|
||||
// and chunk sends, so subsequent broadcast frames (entry/event/state/bus)
|
||||
// queue behind the snapshot on the same socket and the guest can't
|
||||
// observe a gap between the snapshot fragment and live traffic.
|
||||
const snapshot = this.#ctx.sessionManager.snapshotForReplication();
|
||||
if (JSON.stringify(snapshot).length > WELCOME_IMAGE_STRIP_THRESHOLD) {
|
||||
let stripped = 0;
|
||||
@@ -335,18 +343,21 @@ export class CollabHost {
|
||||
logger.info("collab welcome exceeded size threshold; stripped images", { stripped });
|
||||
}
|
||||
const entries = snapshot.entries.filter(isWireSessionEntry);
|
||||
this.#socket?.send(
|
||||
const socket = this.#socket;
|
||||
if (!socket) return;
|
||||
socket.send(
|
||||
{
|
||||
t: "welcome",
|
||||
proto: COLLAB_PROTO,
|
||||
header: snapshot.header,
|
||||
entries,
|
||||
state: this.#buildState(),
|
||||
agents: this.#snapshotAgents(),
|
||||
entryCount: entries.length,
|
||||
readOnly: canWrite ? undefined : true,
|
||||
},
|
||||
fromPeer,
|
||||
);
|
||||
this.#sendSnapshotChunks(entries, fromPeer);
|
||||
this.#ctx.session.emitNotice(
|
||||
"info",
|
||||
`${cleanName} joined the collab session${canWrite ? "" : " (read-only)"}`,
|
||||
@@ -356,6 +367,37 @@ export class CollabHost {
|
||||
this.#scheduleStateBroadcast();
|
||||
}
|
||||
|
||||
/**
|
||||
* Slice {@link entries} into byte-bounded `snapshot-chunk` frames targeted
|
||||
* at {@link fromPeer}. Every batch carries at least one entry (a single
|
||||
* oversize entry ships alone), and the last batch is tagged `final: true`
|
||||
* so the guest can finalize the replica. An empty snapshot still emits one
|
||||
* `final` chunk so the guest never blocks on a missing terminator.
|
||||
*/
|
||||
#sendSnapshotChunks(entries: (StoredSessionEntry & WireSessionEntry)[], fromPeer: number): void {
|
||||
const socket = this.#socket;
|
||||
if (!socket) return;
|
||||
if (entries.length === 0) {
|
||||
socket.send({ t: "snapshot-chunk", entries: [], final: true }, fromPeer);
|
||||
return;
|
||||
}
|
||||
let i = 0;
|
||||
while (i < entries.length) {
|
||||
const batch: (StoredSessionEntry & WireSessionEntry)[] = [];
|
||||
let batchBytes = 0;
|
||||
while (i < entries.length) {
|
||||
const entry = entries[i];
|
||||
if (!entry) break;
|
||||
const entryBytes = JSON.stringify(entry).length;
|
||||
if (batch.length > 0 && batchBytes + entryBytes > SNAPSHOT_CHUNK_BYTES) break;
|
||||
batch.push(entry);
|
||||
batchBytes += entryBytes;
|
||||
i++;
|
||||
}
|
||||
socket.send({ t: "snapshot-chunk", entries: batch, final: i >= entries.length }, fromPeer);
|
||||
}
|
||||
}
|
||||
|
||||
#handlePrompt(text: string, images: ImageContent[] | undefined, fromPeer: number): void {
|
||||
const peer = this.#peers.get(fromPeer);
|
||||
if (!peer?.canWrite) {
|
||||
|
||||
@@ -65,12 +65,27 @@ export type CollabFrame =
|
||||
t: "welcome";
|
||||
proto: number;
|
||||
header: SessionHeader;
|
||||
entries: SessionEntry[];
|
||||
state: CollabSessionState;
|
||||
agents: AgentSnapshot[];
|
||||
/**
|
||||
* Total number of `SessionEntry` items the host will deliver in the
|
||||
* `snapshot-chunk` frames that follow. The guest stays in the
|
||||
* snapshot-loading phase until it has accumulated that many entries
|
||||
* (or a chunk arrives with `final: true`).
|
||||
*/
|
||||
entryCount: number;
|
||||
/** True when this peer joined through a read-only (view) link. */
|
||||
readOnly?: boolean;
|
||||
}
|
||||
/**
|
||||
* Targeted snapshot fragment delivered after `welcome`. Splits a large
|
||||
* transcript across many small frames so the guest's per-chunk progress
|
||||
* timeout resets each time the relay delivers another batch; without
|
||||
* chunking, a multi-MB session has to fit one giant frame inside the
|
||||
* 30 s first-welcome budget. The last chunk carries `final: true` so the
|
||||
* guest can finalize the replica session.
|
||||
*/
|
||||
| { t: "snapshot-chunk"; entries: SessionEntry[]; final: boolean }
|
||||
| { t: "entry"; entry: SessionEntry }
|
||||
| { t: "event"; event: AgentSessionEvent }
|
||||
| { t: "state"; state: CollabSessionState }
|
||||
|
||||
@@ -0,0 +1,268 @@
|
||||
/**
|
||||
* Contract: a large session snapshot is delivered as a small `welcome` frame
|
||||
* plus a train of `snapshot-chunk` frames, so the guest can clear its 30s
|
||||
* first-welcome timeout long before the full transcript arrives — the fix for
|
||||
* [#3144](https://github.com/can1357/oh-my-pi/issues/3144) where a multi-MB
|
||||
* single-frame welcome timed out on the default relay.
|
||||
*
|
||||
* The test drives the production `CollabHost` (real sealing, real envelopes)
|
||||
* through an in-process relay + fake WebSocket, mirroring the relay's
|
||||
* forwarding contract exactly; only the TUI context and the network transport
|
||||
* are stubbed.
|
||||
*/
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it } from "bun:test";
|
||||
import { importRoomKey } from "@oh-my-pi/pi-coding-agent/collab/crypto";
|
||||
import { CollabHost } from "@oh-my-pi/pi-coding-agent/collab/host";
|
||||
import {
|
||||
COLLAB_PROTO,
|
||||
type CollabFrame,
|
||||
parseCollabLink,
|
||||
rewriteEnvelopePeer,
|
||||
unpackEnvelope,
|
||||
} from "@oh-my-pi/pi-coding-agent/collab/protocol";
|
||||
import { CollabSocket } from "@oh-my-pi/pi-coding-agent/collab/relay-client";
|
||||
import type { InteractiveModeContext } from "@oh-my-pi/pi-coding-agent/modes/types";
|
||||
import type { SessionEntry } from "@oh-my-pi/pi-coding-agent/session/session-entries";
|
||||
|
||||
// ── In-memory transport (verbatim copy of the relay used in read-only.test.ts) ──
|
||||
|
||||
let activeRelay: InMemoryRelay | null = null;
|
||||
|
||||
class FakeWebSocket {
|
||||
static readonly CONNECTING = 0;
|
||||
static readonly OPEN = 1;
|
||||
static readonly CLOSING = 2;
|
||||
static readonly CLOSED = 3;
|
||||
|
||||
binaryType = "blob";
|
||||
readyState: number = FakeWebSocket.CONNECTING;
|
||||
readonly role: "host" | "guest";
|
||||
peerId = 0;
|
||||
onopen: (() => void) | null = null;
|
||||
onmessage: ((event: { data: unknown }) => void) | null = null;
|
||||
onerror: (() => void) | null = null;
|
||||
onclose: ((event: { code: number; reason: string }) => void) | null = null;
|
||||
readonly #relay: InMemoryRelay;
|
||||
|
||||
constructor(url: string) {
|
||||
const relay = activeRelay;
|
||||
if (!relay) throw new Error("FakeWebSocket: no active in-memory relay");
|
||||
this.#relay = relay;
|
||||
this.role = new URL(url).searchParams.get("role") === "host" ? "host" : "guest";
|
||||
queueMicrotask(() => {
|
||||
if (this.readyState !== FakeWebSocket.CONNECTING) return;
|
||||
this.readyState = FakeWebSocket.OPEN;
|
||||
relay.connect(this);
|
||||
this.onopen?.();
|
||||
});
|
||||
}
|
||||
|
||||
send(data: Uint8Array): void {
|
||||
if (this.readyState !== FakeWebSocket.OPEN) return;
|
||||
const bytes = new Uint8Array(data);
|
||||
queueMicrotask(() => this.#relay.forward(this, bytes));
|
||||
}
|
||||
|
||||
close(_code?: number): void {
|
||||
if (this.readyState === FakeWebSocket.CLOSED) return;
|
||||
this.readyState = FakeWebSocket.CLOSED;
|
||||
this.#relay.disconnect(this);
|
||||
queueMicrotask(() => this.onclose?.({ code: 1000, reason: "closed" }));
|
||||
}
|
||||
|
||||
deliver(bytes: Uint8Array): void {
|
||||
if (this.readyState !== FakeWebSocket.OPEN) return;
|
||||
const copy = new Uint8Array(bytes);
|
||||
queueMicrotask(() => this.onmessage?.({ data: copy.buffer }));
|
||||
}
|
||||
|
||||
deliverControl(json: string): void {
|
||||
if (this.readyState !== FakeWebSocket.OPEN) return;
|
||||
queueMicrotask(() => this.onmessage?.({ data: json }));
|
||||
}
|
||||
}
|
||||
|
||||
class InMemoryRelay {
|
||||
#host: FakeWebSocket | null = null;
|
||||
readonly #guests = new Map<number, FakeWebSocket>();
|
||||
#nextPeerId = 1;
|
||||
|
||||
connect(ws: FakeWebSocket): void {
|
||||
if (ws.role === "host") {
|
||||
this.#host = ws;
|
||||
return;
|
||||
}
|
||||
ws.peerId = this.#nextPeerId++;
|
||||
this.#guests.set(ws.peerId, ws);
|
||||
this.#host?.deliverControl(JSON.stringify({ t: "peer-joined", peer: ws.peerId }));
|
||||
}
|
||||
|
||||
forward(from: FakeWebSocket, bytes: Uint8Array): void {
|
||||
if (from.role === "host") {
|
||||
const envelope = unpackEnvelope(bytes);
|
||||
if (!envelope) return;
|
||||
if (envelope.peerId === 0) {
|
||||
for (const guest of this.#guests.values()) guest.deliver(bytes);
|
||||
} else {
|
||||
this.#guests.get(envelope.peerId)?.deliver(bytes);
|
||||
}
|
||||
return;
|
||||
}
|
||||
rewriteEnvelopePeer(bytes, from.peerId);
|
||||
this.#host?.deliver(bytes);
|
||||
}
|
||||
|
||||
disconnect(ws: FakeWebSocket): void {
|
||||
if (ws.role === "host") {
|
||||
if (this.#host === ws) this.#host = null;
|
||||
return;
|
||||
}
|
||||
this.#guests.delete(ws.peerId);
|
||||
this.#host?.deliverControl(JSON.stringify({ t: "peer-left", peer: ws.peerId }));
|
||||
}
|
||||
}
|
||||
|
||||
// ── Host harness with a configurable transcript ────────────────────────────
|
||||
|
||||
interface SizedSnapshot {
|
||||
header: { type: "session"; id: string; timestamp: string; cwd: string };
|
||||
entries: SessionEntry[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a synthetic transcript whose total serialized size comfortably
|
||||
* exceeds the host's `SNAPSHOT_CHUNK_BYTES` (512 KB), forcing several
|
||||
* chunks. Each entry is ~16 KB of repeated text, so 96 entries → ~1.5 MB,
|
||||
* cleanly above three chunks without making the test slow.
|
||||
*/
|
||||
function makeLargeSnapshot(): SizedSnapshot {
|
||||
const body = "x".repeat(16 * 1024);
|
||||
const entries: SessionEntry[] = [];
|
||||
for (let i = 0; i < 96; i++) {
|
||||
entries.push({
|
||||
type: "message",
|
||||
id: `e${i}`,
|
||||
parentId: null,
|
||||
timestamp: "2026-06-20T00:00:00Z",
|
||||
message: { role: "user", content: body, timestamp: 0 },
|
||||
});
|
||||
}
|
||||
return {
|
||||
header: { type: "session", id: "sess-large", timestamp: "2026-06-20T00:00:00Z", cwd: "/tmp" },
|
||||
entries,
|
||||
};
|
||||
}
|
||||
|
||||
function makeHostContext(snapshot: SizedSnapshot): InteractiveModeContext {
|
||||
const ctx = {
|
||||
settings: { get: () => "" },
|
||||
sessionManager: {
|
||||
getSessionId: () => snapshot.header.id,
|
||||
getCwd: () => snapshot.header.cwd,
|
||||
snapshotForReplication: () => snapshot,
|
||||
onEntryAppended: undefined,
|
||||
},
|
||||
session: {
|
||||
isStreaming: false,
|
||||
queuedMessageCount: 0,
|
||||
sessionName: "large",
|
||||
model: undefined,
|
||||
thinkingLevel: undefined,
|
||||
subscribe: () => () => {},
|
||||
emitNotice: () => {},
|
||||
promptCustomMessage: () => Promise.resolve(),
|
||||
abort: () => Promise.resolve(),
|
||||
},
|
||||
eventBus: undefined,
|
||||
statusLine: {
|
||||
setCollabStatus: () => {},
|
||||
invalidate: () => {},
|
||||
getCachedContextBreakdown: () => ({ usedTokens: 0, contextWindow: 0 }),
|
||||
},
|
||||
ui: { requestRender: () => {} },
|
||||
showStatus: () => {},
|
||||
collabHost: undefined,
|
||||
};
|
||||
return ctx as unknown as InteractiveModeContext;
|
||||
}
|
||||
|
||||
// ── Shared host/relay ───────────────────────────────────────────────────────
|
||||
|
||||
const RealWebSocket = globalThis.WebSocket;
|
||||
const snapshot = makeLargeSnapshot();
|
||||
let host: CollabHost;
|
||||
|
||||
beforeAll(async () => {
|
||||
globalThis.WebSocket = FakeWebSocket as unknown as typeof WebSocket;
|
||||
activeRelay = new InMemoryRelay();
|
||||
host = new CollabHost(makeHostContext(snapshot));
|
||||
await host.start("ws://localhost:8788");
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
globalThis.WebSocket = RealWebSocket;
|
||||
activeRelay = null;
|
||||
await host.stop("test done");
|
||||
});
|
||||
|
||||
const guestCleanups: (() => void)[] = [];
|
||||
afterEach(() => {
|
||||
for (const cleanup of guestCleanups.splice(0).reverse()) cleanup();
|
||||
});
|
||||
|
||||
describe("collab chunked welcome (#3144)", () => {
|
||||
it("delivers a small welcome before chunking the transcript across multiple frames", async () => {
|
||||
const parsed = parseCollabLink(host.link);
|
||||
if ("error" in parsed) throw new Error(parsed.error);
|
||||
const writeToken = parsed.writeToken ? Buffer.from(parsed.writeToken).toString("base64url") : undefined;
|
||||
const key = await importRoomKey(parsed.key);
|
||||
const socket = new CollabSocket({ wsUrl: parsed.wsUrl, role: "guest", key });
|
||||
guestCleanups.push(() => socket.close());
|
||||
|
||||
const frames: CollabFrame[] = [];
|
||||
const trainDone = Promise.withResolvers<void>();
|
||||
socket.onFrame = frame => {
|
||||
frames.push(frame);
|
||||
if (frame.t === "snapshot-chunk" && frame.final) trainDone.resolve();
|
||||
};
|
||||
socket.onOpen = () => socket.send({ t: "hello", proto: COLLAB_PROTO, name: "test", writeToken });
|
||||
socket.connect();
|
||||
await trainDone.promise;
|
||||
|
||||
const welcomeIdx = frames.findIndex(f => f.t === "welcome");
|
||||
expect(welcomeIdx).toBeGreaterThanOrEqual(0);
|
||||
const welcome = frames[welcomeIdx];
|
||||
if (!welcome || welcome.t !== "welcome") throw new Error("expected welcome frame");
|
||||
|
||||
expect(welcome.entryCount).toBe(snapshot.entries.length);
|
||||
expect(welcome.header.id).toBe(snapshot.header.id);
|
||||
// Critical fix: the welcome itself MUST NOT carry the transcript inline —
|
||||
// inline bytes were what spent the guest's 30s timeout in #3144.
|
||||
const welcomeBytes = JSON.stringify(welcome).length;
|
||||
const snapshotBytes = JSON.stringify(snapshot).length;
|
||||
expect(welcomeBytes).toBeLessThan(snapshotBytes / 10);
|
||||
|
||||
// The chunk train starts immediately after the welcome and the host
|
||||
// queues every chunk synchronously, so no other directed frame may
|
||||
// interleave between them.
|
||||
const chunks: { entries: SessionEntry[]; final: boolean }[] = [];
|
||||
for (let i = welcomeIdx + 1; i < frames.length; i++) {
|
||||
const f = frames[i];
|
||||
if (!f || f.t !== "snapshot-chunk") {
|
||||
throw new Error(`unexpected ${f?.t ?? "missing"} between welcome and final chunk`);
|
||||
}
|
||||
chunks.push({ entries: f.entries, final: f.final });
|
||||
if (f.final) break;
|
||||
}
|
||||
// Three+ chunks proves we honor the 512 KB cap with the 1.5 MB transcript;
|
||||
// only the last carries `final: true`.
|
||||
expect(chunks.length).toBeGreaterThan(1);
|
||||
expect(chunks.at(-1)?.final).toBe(true);
|
||||
expect(chunks.slice(0, -1).every(c => !c.final)).toBe(true);
|
||||
|
||||
const flattened: SessionEntry[] = [];
|
||||
for (const chunk of chunks) flattened.push(...chunk.entries);
|
||||
expect(flattened.length).toBe(snapshot.entries.length);
|
||||
expect(flattened.map(e => e.id)).toEqual(snapshot.entries.map(e => e.id));
|
||||
});
|
||||
});
|
||||
@@ -197,8 +197,20 @@ interface TestGuest {
|
||||
nextFrame(): Promise<CollabFrame>;
|
||||
}
|
||||
|
||||
/** Frames the host broadcasts on its own schedule (debounced state/agents, entry/event/bus taps). */
|
||||
const BROADCAST_FRAME_TYPES: Record<string, true> = { state: true, agents: true, entry: true, event: true, bus: true };
|
||||
/**
|
||||
* Frames the test harness skips: the host's debounced broadcasts (state,
|
||||
* agents, entry, event, bus) and the per-peer snapshot-chunk train that
|
||||
* follows every welcome. They interleave nondeterministically with the
|
||||
* directed welcome/error frames these tests actually assert on.
|
||||
*/
|
||||
const FILTERED_FRAME_TYPES: Record<string, true> = {
|
||||
state: true,
|
||||
agents: true,
|
||||
entry: true,
|
||||
event: true,
|
||||
bus: true,
|
||||
"snapshot-chunk": true,
|
||||
};
|
||||
|
||||
/**
|
||||
* Raw guest speaking the wire protocol directly. `writeToken` overrides the link's token (e.g. forged).
|
||||
@@ -216,7 +228,7 @@ async function joinAsGuest(link: string, name: string, writeTokenOverride?: stri
|
||||
const queue: CollabFrame[] = [];
|
||||
const waiters: ((frame: CollabFrame) => void)[] = [];
|
||||
socket.onFrame = frame => {
|
||||
if (BROADCAST_FRAME_TYPES[frame.t]) return;
|
||||
if (FILTERED_FRAME_TYPES[frame.t]) return;
|
||||
const waiter = waiters.shift();
|
||||
if (waiter) waiter(frame);
|
||||
else queue.push(frame);
|
||||
|
||||
@@ -151,6 +151,10 @@ async function joinAsGuest(link: string, name: string): Promise<TestGuest> {
|
||||
const queue: CollabFrame[] = [];
|
||||
const waiters: ((frame: CollabFrame) => void)[] = [];
|
||||
socket.onFrame = frame => {
|
||||
// The host follows every welcome with a `snapshot-chunk` train carrying
|
||||
// the transcript. This harness ships zero entries, so the chunks are
|
||||
// pure noise around the welcome/prompt-reply assertions.
|
||||
if (frame.t === "snapshot-chunk") return;
|
||||
const waiter = waiters.shift();
|
||||
if (waiter) waiter(frame);
|
||||
else queue.push(frame);
|
||||
|
||||
@@ -2,6 +2,14 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Breaking Changes
|
||||
|
||||
- Bumped `COLLAB_PROTO` to `2`: the `welcome` frame now carries metadata only (header/state/agents/`entryCount`) and the transcript follows in a train of targeted `snapshot-chunk` frames terminated by `final: true`. Old guests speaking proto v1 are rejected with the existing protocol-mismatch error.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed the guest hanging in the "waiting" phase on large host sessions: the client now accumulates `snapshot-chunk` frames into the transcript snapshot and only transitions to `live` after the final chunk lands (or immediately when the host's snapshot is empty). ([#3144](https://github.com/can1357/oh-my-pi/issues/3144))
|
||||
|
||||
## [16.0.10] - 2026-06-18
|
||||
|
||||
### Added
|
||||
|
||||
@@ -197,12 +197,13 @@ function handleHello(name: string, proto: number, fromPeer: number): void {
|
||||
t: "welcome",
|
||||
proto: COLLAB_PROTO,
|
||||
header: fixtureHeader,
|
||||
entries: [...entries],
|
||||
state: buildState(),
|
||||
agents: agents.map(agent => ({ ...agent })),
|
||||
entryCount: entries.length,
|
||||
},
|
||||
fromPeer,
|
||||
);
|
||||
sendFrame({ t: "snapshot-chunk", entries: [...entries], final: true }, fromPeer);
|
||||
console.log(`mock-host: ${cleanName} joined (peer ${fromPeer})`);
|
||||
broadcastState();
|
||||
}
|
||||
|
||||
@@ -236,8 +236,10 @@ export class GuestClient {
|
||||
#applyFrame(frame: HostFrame): void {
|
||||
switch (frame.t) {
|
||||
case "welcome":
|
||||
// Reset accumulator: a fresh welcome arriving mid-load (reconnect)
|
||||
// supersedes any partially-streamed snapshot from the prior session.
|
||||
this.#header = frame.header;
|
||||
this.#entries = [...frame.entries];
|
||||
this.#entries = [];
|
||||
this.#state = frame.state;
|
||||
this.#agents = [...frame.agents];
|
||||
this.#stream = null;
|
||||
@@ -246,12 +248,20 @@ export class GuestClient {
|
||||
this.#progress = new Map();
|
||||
this.#lifecycle = new Map();
|
||||
this.#working = frame.state.isStreaming;
|
||||
this.#phase = "live";
|
||||
this.#endedReason = null;
|
||||
this.#readOnly = frame.readOnly === true;
|
||||
this.#welcomed = true;
|
||||
this.#clearWelcomeTimer();
|
||||
if (frame.entryCount === 0) this.#phase = "live";
|
||||
this.#endedReason = null;
|
||||
break;
|
||||
case "snapshot-chunk": {
|
||||
// Stream transcript fragments into the live snapshot. The host
|
||||
// always closes the train with `final: true`; that flip is what
|
||||
// moves the guest from "waiting" to "live".
|
||||
this.#entries = [...this.#entries, ...frame.entries];
|
||||
if (frame.final) this.#phase = "live";
|
||||
break;
|
||||
}
|
||||
case "entry":
|
||||
this.#entries = [...this.#entries, frame.entry];
|
||||
if (this.#streamDone && frame.entry.type === "message" && frame.entry.message.role === "assistant") {
|
||||
|
||||
@@ -50,13 +50,18 @@ function messageEntry(id: string, message: WireMessage): SessionEntry {
|
||||
return { type: "message", id, parentId: null, timestamp: "2026-06-12T00:00:01Z", message };
|
||||
}
|
||||
|
||||
function welcomeFrame(entries: SessionEntry[] = [], readOnly?: boolean): HostFrame {
|
||||
return { t: "welcome", proto: 1, header: HEADER, entries, state: STATE, agents: AGENTS, readOnly };
|
||||
function welcomeFrame(entryCount = 0, readOnly?: boolean): HostFrame {
|
||||
return { t: "welcome", proto: 2, header: HEADER, state: STATE, agents: AGENTS, entryCount, readOnly };
|
||||
}
|
||||
|
||||
function snapshotChunk(entries: SessionEntry[], final = true): HostFrame {
|
||||
return { t: "snapshot-chunk", entries, final };
|
||||
}
|
||||
|
||||
function liveClient(entries: SessionEntry[] = []): GuestClient {
|
||||
const client = new GuestClient(LINK, "tester");
|
||||
client.applyFrameForTest(welcomeFrame(entries));
|
||||
client.applyFrameForTest(welcomeFrame(entries.length));
|
||||
if (entries.length > 0) client.applyFrameForTest(snapshotChunk(entries));
|
||||
return client;
|
||||
}
|
||||
|
||||
@@ -82,7 +87,7 @@ describe("GuestClient frame apply", () => {
|
||||
it("welcome readOnly flag lands in the snapshot", () => {
|
||||
const client = new GuestClient(LINK, "tester");
|
||||
expect(client.getSnapshot().readOnly).toBe(false);
|
||||
client.applyFrameForTest(welcomeFrame([], true));
|
||||
client.applyFrameForTest(welcomeFrame(0, true));
|
||||
expect(client.getSnapshot().readOnly).toBe(true);
|
||||
});
|
||||
|
||||
|
||||
@@ -2,6 +2,10 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Breaking Changes
|
||||
|
||||
- Bumped `COLLAB_PROTO` to `2`. The `welcome` host frame now carries metadata only (`header`, `state`, `agents`, `entryCount`, optional `readOnly`) — the transcript moves to a new `snapshot-chunk` host frame (`{ entries: SessionEntry[]; final: boolean }`) sent immediately after the welcome. Hosts split large snapshots into multiple chunks; the last chunk carries `final: true`. Old guests speaking proto v1 are rejected with the existing protocol-mismatch error. ([#3144](https://github.com/can1357/oh-my-pi/issues/3144))
|
||||
|
||||
## [15.12.4] - 2026-06-13
|
||||
|
||||
### Changed
|
||||
|
||||
@@ -323,12 +323,25 @@ export type HostFrame =
|
||||
t: "welcome";
|
||||
proto: number;
|
||||
header: SessionHeader;
|
||||
entries: SessionEntry[];
|
||||
state: SessionState;
|
||||
agents: AgentSnapshot[];
|
||||
/**
|
||||
* Total number of `SessionEntry` items the host will deliver in the
|
||||
* `snapshot-chunk` frames that follow. Guests stay in the loading
|
||||
* phase until they have accumulated all of them (or a chunk arrives
|
||||
* with `final: true`).
|
||||
*/
|
||||
entryCount: number;
|
||||
/** True when this peer joined through a read-only (view) link. */
|
||||
readOnly?: boolean;
|
||||
}
|
||||
/**
|
||||
* Targeted snapshot fragment delivered after `welcome`. Hosts split the
|
||||
* transcript into chunks bounded by byte size so a multi-MB session is not
|
||||
* forced through one giant frame the relay may stall on. The last chunk
|
||||
* carries `final: true`; guests finalize the replica on that frame.
|
||||
*/
|
||||
| { t: "snapshot-chunk"; entries: SessionEntry[]; final: boolean }
|
||||
| { t: "entry"; entry: SessionEntry }
|
||||
| { t: "event"; event: AgentEvent }
|
||||
| { t: "state"; state: SessionState }
|
||||
@@ -342,8 +355,16 @@ export type HostFrame =
|
||||
|
||||
export type WireFrame = GuestFrame | HostFrame;
|
||||
|
||||
/** Wire protocol version carried in `hello`; the host rejects mismatches. */
|
||||
export const COLLAB_PROTO = 1;
|
||||
/**
|
||||
* Wire protocol version carried in `hello`; the host rejects mismatches.
|
||||
*
|
||||
* - `1` (legacy): `welcome` carried the full `entries` array inline.
|
||||
* - `2`: `welcome` carries only metadata (header/state/agents/entryCount);
|
||||
* transcript entries follow in `snapshot-chunk` frames, so multi-MB
|
||||
* sessions are not gated on a single welcome frame fitting under the
|
||||
* guest's first-welcome timeout.
|
||||
*/
|
||||
export const COLLAB_PROTO = 2;
|
||||
|
||||
/** Parameter key used for intent tracing (e.g. prompt explanation/reasoning) */
|
||||
export const INTENT_FIELD = "i";
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
|
||||
describe("collab wire constants", () => {
|
||||
it("exports the protocol constants consumed by host, guest, and relay links", () => {
|
||||
expect(COLLAB_PROTO).toBe(1);
|
||||
expect(COLLAB_PROTO).toBe(2);
|
||||
expect(COLLAB_PROMPT_MESSAGE_TYPE).toBe("collab-prompt");
|
||||
expect(ENVELOPE_HEADER_LENGTH).toBe(4);
|
||||
expect(ROOM_ID_BYTES).toBe(16);
|
||||
|
||||
Reference in New Issue
Block a user