Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 35 additions & 1 deletion src-tauri/src/commands/workspace_windows.rs
Original file line number Diff line number Diff line change
Expand Up @@ -897,7 +897,7 @@ mod tests {
pending: vec![remote(1), remote(2)],
..SessionState::default()
};
// Remote 2 is opened by hand before its (failed) check is processed.
// Remote 2 is opened by hand before its build failure is processed.
state.mark_open(remote(2));
state.complete_restore();
assert_eq!(state.snapshot(), vec![remote(1), remote(2)]);
Expand Down Expand Up @@ -1025,6 +1025,40 @@ mod tests {
assert_eq!(restore.plan.front, remote(b.id));
}

#[tokio::test]
async fn an_offline_remote_shell_survives_quit_but_an_explicit_close_does_not() {
let db = fresh_in_memory_db().await;
// Restoring a saved shell must not contact this unreachable host.
let connection = remote_workspace_connection_service::create(
&db.conn,
"offline",
"http://127.0.0.1:1",
"token",
&[],
)
.await
.unwrap();
let window = remote(connection.id);
let session = WorkspaceWindowSession::new();
session.apply(|state| state.mark_open(window));
session.freeze();
session.write_latest(&db.conn).await;
let restore = load_restore(&db.conn, false, true).await;
assert!(!restore.show_local());
assert_eq!(restore.plan.remotes, vec![connection.id]);

// A shell closing deliberately while the server remains unavailable
// still leaves the session. We do not revive every saved connection.
let closed_session = WorkspaceWindowSession::new();
closed_session.apply(|state| state.mark_open(window));
closed_session.apply(|state| state.mark_closed(window));
closed_session.freeze();
closed_session.write_latest(&db.conn).await;
let restore = load_restore(&db.conn, false, true).await;
assert!(restore.show_local());
assert!(restore.plan.remotes.is_empty());
}

#[tokio::test]
async fn an_unchanged_list_is_not_written_again() {
let db = fresh_in_memory_db().await;
Expand Down
145 changes: 145 additions & 0 deletions src/components/connection/remote-connection-status.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
import { act, cleanup, fireEvent, render, screen } from "@testing-library/react"
import { NextIntlClientProvider } from "next-intl"
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
import enMessages from "@/i18n/messages/en.json"
import type { RemoteConnectionState } from "@/lib/transport/remote-desktop-transport"
import { RemoteConnectionStatus } from "./remote-connection-status"

function createHealth() {
let state: RemoteConnectionState = "reconnecting"
const listeners = new Set<() => void>()
return {
listeners,
getConnectionSnapshot: () => state,
subscribeConnection: (callback: () => void) => {
listeners.add(callback)
return () => {
listeners.delete(callback)
}
},
eventStream: vi.fn(() => ({ attach: vi.fn() })),
reconnectNow: vi.fn(),
setState(next: RemoteConnectionState) {
state = next
for (const listener of listeners) listener()
},
}
}

function renderStatus(transport = createHealth()) {
const view = render(
<NextIntlClientProvider locale="en" messages={enMessages}>
<div>Workspace stays open</div>
<RemoteConnectionStatus transport={transport} />
</NextIntlClientProvider>
)
return { ...view, transport }
}

beforeEach(() => vi.useFakeTimers())
afterEach(() => {
cleanup()
vi.useRealTimers()
})

describe("RemoteConnectionStatus", () => {
it("starts the shared proxy but hides brief initial connects", () => {
const { transport } = renderStatus()
expect(transport.eventStream).toHaveBeenCalledTimes(1)
expect(screen.queryByRole("status")).not.toBeInTheDocument()
act(() => {
vi.advanceTimersByTime(3_999)
transport.setState("connected")
})
act(() => vi.advanceTimersByTime(10_000))
expect(screen.queryByRole("status")).not.toBeInTheDocument()
})

it("shows initial offline retries, then an honest terminal state and reconnect action", () => {
const { transport } = renderStatus()
act(() => vi.advanceTimersByTime(4_000))
expect(screen.getByRole("status")).toHaveTextContent("Connection lost")
expect(
screen.getByText(enMessages.WebConnection.reconnectingDescription)
).toBeInTheDocument()

act(() => transport.setState("disconnected"))
expect(
screen.queryByText(enMessages.WebConnection.reconnectingDescription)
).not.toBeInTheDocument()
expect(screen.queryByText("Session expired")).not.toBeInTheDocument()
fireEvent.click(screen.getByRole("button", { name: "Reconnect now" }))
expect(transport.reconnectNow).toHaveBeenCalledTimes(1)
expect(screen.getByText("Workspace stays open")).toBeInTheDocument()
})

it("shows terminal failures immediately and clears when the connection recovers", () => {
const { transport } = renderStatus()
act(() => transport.setState("disconnected"))
expect(screen.getByRole("status")).toHaveTextContent("Connection lost")
act(() => transport.setState("connected"))
expect(screen.queryByRole("status")).not.toBeInTheDocument()
})

it("starts a fresh grace window for each later outage", () => {
const { transport } = renderStatus()
act(() => vi.advanceTimersByTime(4_000))
expect(screen.getByRole("status")).toBeInTheDocument()
act(() => transport.setState("connected"))
act(() => transport.setState("reconnecting"))
act(() => vi.advanceTimersByTime(3_999))
expect(screen.queryByRole("status")).not.toBeInTheDocument()
act(() => vi.advanceTimersByTime(1))
expect(screen.getByRole("status")).toBeInTheDocument()
})

it("shows a requested retry's progress at once, without the grace window", () => {
const { transport } = renderStatus()
act(() => transport.setState("disconnected"))
transport.reconnectNow.mockImplementation(() =>
transport.setState("reconnecting")
)

fireEvent.click(screen.getByRole("button", { name: "Reconnect now" }))
expect(screen.getByRole("status")).toHaveTextContent("Connection lost")
expect(
screen.getByText(enMessages.WebConnection.reconnectingDescription)
).toBeInTheDocument()
expect(
screen.queryByRole("button", { name: "Reconnect now" })
).not.toBeInTheDocument()

// The retry gives up again: the action is back.
act(() => transport.setState("disconnected"))
fireEvent.click(screen.getByRole("button", { name: "Reconnect now" }))
expect(screen.getByRole("status")).toBeInTheDocument()

// Once a retry has connected, a later outage gets its grace again.
act(() => transport.setState("connected"))
act(() => transport.setState("reconnecting"))
expect(screen.queryByRole("status")).not.toBeInTheDocument()
act(() => vi.advanceTimersByTime(4_000))
expect(screen.getByRole("status")).toBeInTheDocument()
})

it("keeps the grace window after a retry the transport did not start", () => {
const { transport } = renderStatus()
act(() => transport.setState("disconnected"))

// The mock ignores the retry, as the transport does while one is running.
fireEvent.click(screen.getByRole("button", { name: "Reconnect now" }))
expect(transport.reconnectNow).toHaveBeenCalledTimes(1)
act(() => transport.setState("connected"))
act(() => transport.setState("reconnecting"))

expect(screen.queryByRole("status")).not.toBeInTheDocument()
})

it("unsubscribes and cancels the grace timer on unmount", () => {
const { transport, unmount } = renderStatus()
expect(transport.listeners.size).toBe(1)
unmount()
expect(transport.listeners.size).toBe(0)
expect(vi.getTimerCount()).toBe(0)
})
})
98 changes: 98 additions & 0 deletions src/components/connection/remote-connection-status.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
"use client"

import { useCallback, useEffect, useState, useSyncExternalStore } from "react"
import { Loader2, WifiOff } from "lucide-react"
import { useTranslations } from "next-intl"
import { Button } from "@/components/ui/button"
import type { RemoteDesktopTransport } from "@/lib/transport/remote-desktop-transport"

type ConnectionHealth = Pick<
RemoteDesktopTransport,
| "getConnectionSnapshot"
| "subscribeConnection"
| "eventStream"
| "reconnectNow"
>

/** Mounted after the remote transport is configured, so it cannot subscribe
* to the local shell by mistake. Keep the workspace mounted during an outage,
* which the proxy keeps retrying, and show a manual recovery action once this
* window's subscription to it has stopped. */
export function RemoteConnectionStatus({
transport,
}: {
transport: ConnectionHealth
}) {
const t = useTranslations("WebConnection")
const subscribe = useCallback(
(callback: () => void) => transport.subscribeConnection(callback),
[transport]
)
const getSnapshot = useCallback(
() => transport.getConnectionSnapshot(),
[transport]
)
const state = useSyncExternalStore(subscribe, getSnapshot, () => "connected")
const [graceElapsed, setGraceElapsed] = useState(false)
// A retry the user just asked for shows its progress at once: the pill is
// already up, and hiding it for the grace window reads as "nothing
// happened".
const [retryRequested, setRetryRequested] = useState(false)

useEffect(() => {
// Settings and other restored remote shells may have no conversation
// subscribers yet. Start the same shared proxy, not a second socket.
transport.eventStream()
}, [transport])

useEffect(() => {
if (state !== "reconnecting") return
const timer = setTimeout(() => setGraceElapsed(true), 4_000)
return () => {
clearTimeout(timer)
setGraceElapsed(false)
setRetryRequested(false)
}
}, [state])

const retry = () => {
transport.reconnectNow()
// Only a retry that started: the transport ignores one while another
// is still under way.
setRetryRequested(transport.getConnectionSnapshot() === "reconnecting")
}

if (
state === "connected" ||
(state === "reconnecting" && !graceElapsed && !retryRequested)
) {
return null
}

const retrying = state === "reconnecting"
return (
<div
role="status"
className="fixed inset-x-4 bottom-4 z-50 mx-auto flex w-fit max-w-[calc(100%_-_2rem)] items-center gap-3 rounded-xl border bg-background px-4 py-3 text-sm shadow-lg"
>
{retrying ? (
<Loader2 className="size-4 shrink-0 animate-spin" />
) : (
<WifiOff className="size-4 shrink-0 text-destructive" />
)}
<div>
<p>{t("disconnectedTitle")}</p>
{retrying && (
<p className="text-muted-foreground">
{t("reconnectingDescription")}
</p>
)}
</div>
{!retrying && (
<Button size="sm" onClick={retry}>
{t("reconnectNow")}
</Button>
)}
</div>
)
}
10 changes: 10 additions & 0 deletions src/contexts/remote-connection-context.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,10 @@ import { useTranslations } from "next-intl"
import {
clearRemoteDesktopTransport,
configureRemoteDesktopTransport,
getTransport,
} from "@/lib/transport"
import type { RemoteDesktopTransport } from "@/lib/transport/remote-desktop-transport"
import { RemoteConnectionStatus } from "@/components/connection/remote-connection-status"
import { resetBackendScopedStores } from "@/stores/backend-scoped-store-reset"
import { getRemoteWorkspaceConnection } from "@/lib/remote-workspace"
import { toErrorMessage } from "@/lib/app-error"
Expand Down Expand Up @@ -107,6 +110,8 @@ export function RemoteConnectionGate({ children }: { children: ReactNode }) {
baseUrl: next.base_url,
token: next.token,
windowInstanceId: remoteWindowId,
// Only a refused token. A socket that is down keeps the workspace
// mounted while it retries; `RemoteConnectionStatus` shows that.
onUnauthorized: () =>
setState((prev) => ({ ...prev, expired: true })),
})
Expand Down Expand Up @@ -213,6 +218,11 @@ export function RemoteConnectionGate({ children }: { children: ReactNode }) {
return (
<RemoteConnectionContext.Provider value={value}>
{children}
{hasRemoteConnection && (
<RemoteConnectionStatus
transport={getTransport() as RemoteDesktopTransport}
/>
)}
</RemoteConnectionContext.Provider>
)
}
Loading
Loading