Fix restored terminal reconnect (#2313)
* fix: reconnect restored terminals - Reopen terminal mux after restored sessions become live - Cover restore with unchanged terminal handles * fix: limit restored terminal reconnects * chore: format with prettier [skip ci] --------- Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
This commit is contained in:
parent
cc75a519ab
commit
5d7ad6137d
|
|
@ -263,6 +263,72 @@ describe("useTerminalSession", () => {
|
||||||
expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: workspaceQueryKey });
|
expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: workspaceQueryKey });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("reconnects when a restored session becomes live with the same terminal handle", () => {
|
||||||
|
const muxes: FakeMux[] = [];
|
||||||
|
const createMux = () => {
|
||||||
|
const fake = createFakeMux();
|
||||||
|
muxes.push(fake);
|
||||||
|
return fake.mux;
|
||||||
|
};
|
||||||
|
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||||
|
const wrapper = ({ children }: { children: ReactNode }) => (
|
||||||
|
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||||
|
);
|
||||||
|
const view = renderHook(
|
||||||
|
({ attachedSession }) => useTerminalSession(attachedSession, { daemonReady: true, createMux }),
|
||||||
|
{ initialProps: { attachedSession: session }, wrapper },
|
||||||
|
);
|
||||||
|
const terminal = createFakeTerminal();
|
||||||
|
act(() => {
|
||||||
|
view.result.current.attach(terminal);
|
||||||
|
});
|
||||||
|
act(() => muxes[0].emitOpened("handle-1"));
|
||||||
|
act(() => muxes[0].emitExit("handle-1"));
|
||||||
|
expect(view.result.current.state).toBe("exited");
|
||||||
|
|
||||||
|
view.rerender({ attachedSession: { ...session, status: "terminated", updatedAt: "terminated" } });
|
||||||
|
expect(muxes).toHaveLength(1);
|
||||||
|
|
||||||
|
view.rerender({ attachedSession: { ...session, status: "idle", updatedAt: "restored" } });
|
||||||
|
expect(view.result.current.state).toBe("connecting");
|
||||||
|
expect(muxes).toHaveLength(2);
|
||||||
|
expect(muxes[0].disposed).toBe(true);
|
||||||
|
expect(muxes[1].opens).toEqual([["handle-1", 80, 24]]);
|
||||||
|
act(() => muxes[1].emitOpened("handle-1"));
|
||||||
|
expect(view.result.current.state).toBe("attached");
|
||||||
|
terminal.typeKeys("echo ok\r");
|
||||||
|
expect(muxes[1].inputs).toEqual([["handle-1", "echo ok\r"]]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not reconnect a broken live pane on ordinary session updates", () => {
|
||||||
|
const muxes: FakeMux[] = [];
|
||||||
|
const createMux = () => {
|
||||||
|
const fake = createFakeMux();
|
||||||
|
muxes.push(fake);
|
||||||
|
return fake.mux;
|
||||||
|
};
|
||||||
|
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||||
|
const wrapper = ({ children }: { children: ReactNode }) => (
|
||||||
|
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||||
|
);
|
||||||
|
const view = renderHook(
|
||||||
|
({ attachedSession }) => useTerminalSession(attachedSession, { daemonReady: true, createMux }),
|
||||||
|
{ initialProps: { attachedSession: session }, wrapper },
|
||||||
|
);
|
||||||
|
const terminal = createFakeTerminal();
|
||||||
|
act(() => {
|
||||||
|
view.result.current.attach(terminal);
|
||||||
|
});
|
||||||
|
act(() => muxes[0].emitError("handle-1", "no such pane"));
|
||||||
|
expect(view.result.current.state).toBe("error");
|
||||||
|
|
||||||
|
view.rerender({ attachedSession: { ...session, status: "idle", updatedAt: "tick-1" } });
|
||||||
|
view.rerender({ attachedSession: { ...session, status: "working", updatedAt: "tick-2" } });
|
||||||
|
|
||||||
|
expect(view.result.current.state).toBe("error");
|
||||||
|
expect(muxes).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
it("surfaces pane errors and refetches, with no automatic retry", () => {
|
it("surfaces pane errors and refetches, with no automatic retry", () => {
|
||||||
const { view, muxes, invalidateSpy } = setup();
|
const { view, muxes, invalidateSpy } = setup();
|
||||||
act(() => muxes[0].emitError("handle-1", "no such pane"));
|
act(() => muxes[0].emitError("handle-1", "no such pane"));
|
||||||
|
|
|
||||||
|
|
@ -80,6 +80,7 @@ export function useTerminalSession(session: WorkspaceSession | undefined, option
|
||||||
|
|
||||||
const sessionRef = useRef(session);
|
const sessionRef = useRef(session);
|
||||||
sessionRef.current = session;
|
sessionRef.current = session;
|
||||||
|
const previousSessionStatusRef = useRef(session?.status);
|
||||||
const optionsRef = useRef(options);
|
const optionsRef = useRef(options);
|
||||||
optionsRef.current = options;
|
optionsRef.current = options;
|
||||||
const stateRef = useRef<TerminalSessionState>(state);
|
const stateRef = useRef<TerminalSessionState>(state);
|
||||||
|
|
@ -323,6 +324,24 @@ export function useTerminalSession(session: WorkspaceSession | undefined, option
|
||||||
connect();
|
connect();
|
||||||
}, [daemonReady, connect]);
|
}, [daemonReady, connect]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const r = runtime.current;
|
||||||
|
const handle = session?.terminalHandleId ?? null;
|
||||||
|
const previousStatus = previousSessionStatusRef.current;
|
||||||
|
previousSessionStatusRef.current = session?.status;
|
||||||
|
if (!handle || previousStatus !== "terminated" || session?.status === "terminated" || r.detached || !r.terminal) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (r.handle !== handle) return;
|
||||||
|
if (stateRef.current !== "exited" && stateRef.current !== "error") return;
|
||||||
|
if (optionsRef.current.daemonReady) {
|
||||||
|
transition("connecting");
|
||||||
|
connect();
|
||||||
|
} else {
|
||||||
|
transition("reattaching");
|
||||||
|
}
|
||||||
|
}, [connect, session?.status, session?.terminalHandleId, transition]);
|
||||||
|
|
||||||
// Belt-and-braces: never leak a socket past unmount, even if the owner
|
// Belt-and-braces: never leak a socket past unmount, even if the owner
|
||||||
// forgot to call detach.
|
// forgot to call detach.
|
||||||
useEffect(
|
useEffect(
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue