diff --git a/apps/desktop/src/components/ssh/SshHostKeyPromptDialog.vue b/apps/desktop/src/components/ssh/SshHostKeyPromptDialog.vue index 94317e385..79970d053 100644 --- a/apps/desktop/src/components/ssh/SshHostKeyPromptDialog.vue +++ b/apps/desktop/src/components/ssh/SshHostKeyPromptDialog.vue @@ -42,6 +42,7 @@ const secretCode = ref(""); const resolving = ref(false); const unlisteners: Array<() => void> = []; let webEventSource: EventSource | null = null; +let pendingTimer: ReturnType | 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); }); diff --git a/apps/desktop/src/components/ssh/__tests__/SshHostKeyPromptDialog.spec.ts b/apps/desktop/src/components/ssh/__tests__/SshHostKeyPromptDialog.spec.ts index 661903f44..bca1a8cea 100644 --- a/apps/desktop/src/components/ssh/__tests__/SshHostKeyPromptDialog.spec.ts +++ b/apps/desktop/src/components/ssh/__tests__/SshHostKeyPromptDialog.spec.ts @@ -54,6 +54,7 @@ class MockEventSource { readonly url: string; onmessage: ((event: MessageEvent) => 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); + }); }); diff --git a/crates/dbx-web/src/main.rs b/crates/dbx-web/src/main.rs index b703bc9a6..38bf1b5d1 100644 --- a/crates/dbx-web/src/main.rs +++ b/crates/dbx-web/src/main.rs @@ -312,6 +312,7 @@ async fn main() { .route("/system/fonts", get(routes::jdbc::list_system_fonts)) .route("/ssh/config-hosts", get(routes::ssh_config::list_ssh_config_hosts)) .route("/ssh/prompts", get(routes::ssh_prompt::stream_ssh_prompts)) + .route("/ssh/prompts/pending", get(routes::ssh_prompt::list_pending_ssh_prompts)) .route("/ssh/prompts/resolve", post(routes::ssh_prompt::resolve_ssh_prompt)) // Tunnel profiles .route("/tunnel-profiles/list", get(routes::tunnel_profiles::load_tunnel_profiles)) diff --git a/crates/dbx-web/src/routes/ssh_prompt.rs b/crates/dbx-web/src/routes/ssh_prompt.rs index f84748923..3152bd928 100644 --- a/crates/dbx-web/src/routes/ssh_prompt.rs +++ b/crates/dbx-web/src/routes/ssh_prompt.rs @@ -1,7 +1,7 @@ use axum::extract::State; use axum::response::sse::{Event, KeepAlive, Sse}; use axum::Json; -use dbx_core::db::ssh_prompt::SshPromptAnswer; +use dbx_core::db::ssh_prompt::{SshPromptAnswer, SshPromptRequest}; use futures::Stream; use serde::Deserialize; use std::convert::Infallible; @@ -54,6 +54,13 @@ pub async fn resolve_ssh_prompt( Ok(Json(())) } +/// Returns all currently-pending host-key prompts. The frontend polls this as a +/// fallback to the SSE stream so a prompt that fired before the EventSource was +/// open is still recovered (see the SSH host-key first-connect regression). +pub async fn list_pending_ssh_prompts(State(state): State>) -> Json> { + Json(state.ssh_prompts.pending_requests()) +} + fn ssh_prompt_event(event: SshPromptEvent) -> Event { Event::default().data(serde_json::to_string(&event).expect("SSH prompt events must serialize")) } diff --git a/crates/dbx-web/src/ssh_prompt.rs b/crates/dbx-web/src/ssh_prompt.rs index 8f443791c..23ba5aa5b 100644 --- a/crates/dbx-web/src/ssh_prompt.rs +++ b/crates/dbx-web/src/ssh_prompt.rs @@ -47,6 +47,14 @@ impl SshPromptHub { (replay, receiver) } + /// Snapshot of all currently-pending prompt requests. The frontend uses this + /// as a fallback alongside the SSE stream: an SSE `Prompt` event that was + /// lost (e.g. the EventSource was not yet open when the prompt fired) is + /// recovered by polling this endpoint. Duplicates are deduped by the caller. + pub fn pending_requests(&self) -> Vec { + self.pending.lock().unwrap().iter().map(|prompt| prompt.request.clone()).collect() + } + pub fn resolve(&self, id: &str, answer: SshPromptAnswer) -> Result<(), String> { let pending = { let mut pending = self.pending.lock().unwrap(); @@ -159,4 +167,24 @@ mod tests { hub.resolve("prompt-2", SshPromptAnswer::Reject).unwrap(); assert!(matches!(receiver.await.unwrap(), SshPromptAnswer::Reject)); } + + #[tokio::test] + async fn pending_requests_snapshots_unresolved_prompts_and_drops_resolved() { + let hub = SshPromptHub::new(); + let (responder_a, _ra) = tokio::sync::oneshot::channel(); + let (responder_b, _rb) = tokio::sync::oneshot::channel(); + hub.register(SshPromptEnvelope { request: request("prompt-a"), responder: responder_a }); + hub.register(SshPromptEnvelope { request: request("prompt-b"), responder: responder_b }); + + let pending = hub.pending_requests(); + assert_eq!(pending.len(), 2); + assert_eq!(pending[0].id, "prompt-a"); + assert_eq!(pending[1].id, "prompt-b"); + + // A resolved prompt is removed from the pending snapshot. + hub.resolve("prompt-a", SshPromptAnswer::Reject).unwrap(); + let pending = hub.pending_requests(); + assert_eq!(pending.len(), 1); + assert_eq!(pending[0].id, "prompt-b"); + } }