fix(ssh): recover first-connect host-key prompts on web

This commit is contained in:
hhzorg authored and GitHub committed 2026-08-03 16:59:34 +08:00
1 parent db791b5aed
commit 7fbee89144
5 files changed
+132 -3

No files matched your search

@@ -42,6 +42,7 @@ const secretCode = ref("");
const resolving = ref(false);
const unlisteners: Array<() => void> = [];
let webEventSource: EventSource | null = null;
let pendingTimer: ReturnType<typeof setInterval> | null = null;
let mounted = true;
function enqueuePrompt(request: SshPromptRequest) {
@@ -125,12 +126,43 @@ async function setupTauriPromptBridge() {
}
}
// Fallback delivery alongside the SSE stream. On first connect the dialog's
// EventSource may not be open yet when the backend fires a host-key prompt, so
// the SSE `Prompt` event is lost and (without this) the connection hangs until
// its timeout. Polling the pending endpoint recovers any lost prompt so the
// dialog still appears. `enqueuePrompt` dedupes by id, so repeated polls are
// harmless.
async function fetchPendingPrompts() {
try {
const res = await fetch(apiUrl("/api/ssh/prompts/pending"), { credentials: "include" });
if (!res.ok) return;
const pending = (await res.json()) as SshPromptRequest[];
pending.forEach((req) => enqueuePrompt(req));
} catch {
// Transient failure — the next poll retries.
}
}
function setupWebPromptBridge() {
webEventSource = new EventSource(apiUrl("/api/ssh/prompts"));
webEventSource.onmessage = (event) => handleWebEvent(event.data);
webEventSource.onerror = () => {
// EventSource automatically retries. Do not close it while the app is mounted.
webEventSource.onopen = () => {
// The SSE stream is live; its replay already carries any prompt that fired
// before the stream opened, so stop polling to avoid needless requests.
if (pendingTimer) {
clearInterval(pendingTimer);
pendingTimer = null;
}
};
webEventSource.onerror = () => {
// EventSource reconnects automatically. While disconnected, fall back to
// polling so a prompt fired during the outage is not lost.
if (!pendingTimer) pendingTimer = setInterval(fetchPendingPrompts, 2000);
};
// First-connect fallback: the stream may not be open yet when the backend
// fires a host-key prompt, so poll until it is.
void fetchPendingPrompts();
if (!pendingTimer) pendingTimer = setInterval(fetchPendingPrompts, 2000);
}
onMounted(() => {
@@ -143,6 +175,10 @@ onBeforeUnmount(() => {
unlisteners.forEach((u) => u());
webEventSource?.close();
webEventSource = null;
if (pendingTimer) {
clearInterval(pendingTimer);
pendingTimer = null;
}
if (isTauriRuntime()) void import("@tauri-apps/api/core").then(({ invoke }) => invoke("ssh_prompt_not_ready")).catch(() => undefined);
});
@@ -54,6 +54,7 @@ class MockEventSource {
readonly url: string;
onmessage: ((event: MessageEvent<string>) => void) | null = null;
onopen: (() => void) | null = null;
onerror: (() => void) | null = null;
closed = false;
@@ -66,6 +67,14 @@ class MockEventSource {
this.onmessage?.(new MessageEvent("message", { data: JSON.stringify(data) }));
}
open() {
this.onopen?.();
}
error() {
this.onerror?.();
}
close() {
this.closed = true;
}
@@ -77,6 +86,7 @@ beforeEach(() => {
resolveSshPromptMock.mockReset().mockResolvedValue(undefined);
MockEventSource.instances = [];
vi.stubGlobal("EventSource", MockEventSource as unknown as typeof EventSource);
vi.stubGlobal("fetch", vi.fn().mockResolvedValue({ ok: true, json: async () => [] } as Response));
i18n.global.locale.value = "en";
});
@@ -160,4 +170,51 @@ describe("SshHostKeyPromptDialog web bridge", () => {
await nextTick();
expect(document.body.textContent).not.toContain("stale.example.test:22");
});
it("recovers a pending host-key prompt via the polling fallback when the SSE event is missed", async () => {
// Simulate a prompt that fired before the EventSource was open: the backend
// has it pending, but the SSE `Prompt` event never reached the dialog. The
// polling fallback must surface it so the user can still confirm.
const fetchMock = vi.mocked(fetch);
fetchMock.mockResolvedValueOnce({
ok: true,
json: async () => [
{
id: "prompt-3",
kind: "HostKeyVerify",
host: "first.example.test",
port: 22,
key_type: "ssh-ed25519",
fingerprint: "SHA256:first",
},
],
} as Response);
await mountDialog();
await nextTick();
await vi.waitFor(() => {
expect(document.body.textContent).toContain("first.example.test:22");
});
expect(fetchMock).toHaveBeenCalledWith("/api/ssh/prompts/pending", { credentials: "include" });
});
it("stops polling once the SSE stream opens and resumes it after an error", async () => {
const setSpy = vi.spyOn(globalThis, "setInterval");
const clearSpy = vi.spyOn(globalThis, "clearInterval");
await mountDialog();
const eventSource = MockEventSource.instances[0];
// Polling is armed on mount (first-connect fallback).
expect(setSpy).toHaveBeenCalled();
// Opening the stream stops the poller (SSE replay now covers lost prompts).
eventSource?.open();
expect(clearSpy).toHaveBeenCalled();
// A disconnect re-arms polling so prompts are not lost during the outage.
eventSource?.error();
expect(setSpy).toHaveBeenCalledTimes(2);
});
});