fix(collab): reject join when snapshot resume fails

The chunked welcome path cleared its snapshot progress timer before
writing the replica file and switching sessions. If that apply work
failed, the frame-apply catch only logged the error, leaving the
initial join promise pending with no welcome/progress timer left to
settle it.

Reject the pending initial join when a welcome or snapshot-chunk apply
fails before the join has completed, preserving reconnect-time logging
for already-joined guests. Add a regression test that forces the replica
write to fail and asserts /join rejects instead of hanging.

Fixes #3144
This commit is contained in:
roboomp
2026-06-20 18:45:04 +00:00
parent 4ff5b073e8
commit 10e1ff44c9
2 changed files with 64 additions and 2 deletions
+6 -1
View File
@@ -232,7 +232,12 @@ export class CollabGuestLink {
if (!this.#welcomed || this.#left) return;
this.#applyFrame(frame);
})
.catch(err => logger.warn("collab guest frame apply failed", { type: frame.t, error: String(err) }));
.catch(err => {
logger.warn("collab guest frame apply failed", { type: frame.t, error: String(err) });
if (!joined && (frame.t === "welcome" || frame.t === "snapshot-chunk")) {
firstWelcome.reject(err instanceof Error ? err : new Error(String(err)));
}
});
};
socket.onClose = (reason, willReconnect) => {
this.#clearWelcomeTimer();
@@ -10,8 +10,9 @@
* forwarding contract exactly; only the TUI context and the network transport
* are stubbed.
*/
import { afterAll, afterEach, beforeAll, describe, expect, it } from "bun:test";
import { afterAll, afterEach, beforeAll, describe, expect, it, spyOn } from "bun:test";
import { importRoomKey } from "@oh-my-pi/pi-coding-agent/collab/crypto";
import { CollabGuestLink } from "@oh-my-pi/pi-coding-agent/collab/guest";
import { CollabHost } from "@oh-my-pi/pi-coding-agent/collab/host";
import {
COLLAB_PROTO,
@@ -186,6 +187,42 @@ function makeHostContext(snapshot: SizedSnapshot): InteractiveModeContext {
return ctx as unknown as InteractiveModeContext;
}
function makeFailingGuestContext(failure: Error): InteractiveModeContext {
const ctx = {
settings: { get: () => "" },
sessionManager: {
getSessionFile: () => null,
switchSession: () => Promise.reject(failure),
},
session: {
newSession: () => Promise.resolve(),
messages: [],
},
statusContainer: { clear: () => {} },
pendingMessagesContainer: { clear: () => {} },
compactionQueuedMessages: [],
streamingComponent: undefined,
streamingMessage: undefined,
pendingTools: new Map(),
loadingAnimation: undefined,
statusLine: {
setCollabStatus: () => {},
invalidate: () => {},
setSessionStartTime: () => {},
},
ui: { requestRender: () => {} },
chatContainer: { clear: () => {} },
resetObserverRegistry: () => {},
renderInitialMessages: () => {},
reloadTodos: () => Promise.resolve(),
showStatus: () => {},
updateEditorTopBorder: () => {},
updateEditorBorderColor: () => {},
collabGuest: undefined,
} as unknown as InteractiveModeContext;
return ctx;
}
// ── Shared host/relay ───────────────────────────────────────────────────────
const RealWebSocket = globalThis.WebSocket;
@@ -265,4 +302,24 @@ describe("collab chunked welcome (#3144)", () => {
expect(flattened.length).toBe(snapshot.entries.length);
expect(flattened.map(e => e.id)).toEqual(snapshot.entries.map(e => e.id));
});
it("rejects the pending join when snapshot resume fails", async () => {
const failure = new Error("replica write failed during snapshot resume");
const writeSpy = spyOn(Bun, "write").mockRejectedValue(failure);
const guest = new CollabGuestLink(makeFailingGuestContext(failure));
const joinAttempt = guest.join(host.link);
try {
await expect(
Promise.race([
joinAttempt,
Bun.sleep(250).then(() => {
throw new Error("join did not reject");
}),
]),
).rejects.toThrow("replica write failed during snapshot resume");
} finally {
writeSpy.mockRestore();
await guest.leave("test cleanup").catch(() => {});
}
});
});