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:
@@ -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(() => {});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user