Skip to content
Open
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
6 changes: 5 additions & 1 deletion packages/workshop-frontend/src/GadgetEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
} from '@phosphor-icons/react'
import { RpcStub, RpcTarget } from 'capnweb'
import { useAuthenticatedApi } from './AuthContext'
import { useConnectionLost } from './RpcContext'
import UserMenu from './components/UserMenu'
import SiteLogo from './components/SiteLogo'

Expand Down Expand Up @@ -447,6 +448,9 @@ export default function GadgetEditor() {
isEditingTitleRef.current = isEditingTitle
const [titleInput, setTitleInput] = useState('')

// The workspace-level flag covers reopen failures; the socket-level flag covers the outage
// window itself, during which the dead stub stays published and no reopen is attempted yet.
const rpcConnectionLost = useConnectionLost()
const {
overseer,
metadata,
Expand Down Expand Up @@ -1418,7 +1422,7 @@ export default function GadgetEditor() {
onViewActivity={openActivity}
/>

{connectionLost && <ReconnectingChip />}
{(connectionLost || rpcConnectionLost) && <ReconnectingChip />}

<WorkshopIconButton
onClick={() => setShareModalOpen(true)}
Expand Down
124 changes: 89 additions & 35 deletions packages/workshop-frontend/src/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,28 @@ async function devAutoLogin(stub: RpcStub<PublicApi>): Promise<void> {
//
// Anyway, I pulled the connection management out into these globals instead.
let lastConnectTime: number = 0;
let backoff: number = 1000;

const INITIAL_BACKOFF_MS = 1000;
const MAX_BACKOFF_MS = 10000;
// The probe is getServerConfig — the same call the app needs to boot anyway. Generous deadlines
// let a slow-but-alive backend settle instead of connect/dispose looping (or, on wake, tearing
// down a healthy socket under load).
const RECONNECT_PROBE_TIMEOUT_MS = 20000;
const WAKE_PROBE_TIMEOUT_MS = 10000;
const WAKE_PROBE_MIN_IDLE_MS = 15000;

// Callbacks to call whenever `currentStub` or connection state is updated.
const subscribers = new Set<() => void>();
const notifySubscribers = () => subscribers.forEach(cb => cb());
let isConnectionLost = false;
let reconnecting = false;
let probing = false;
let lastProvenAt = Date.now();

const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms));

const withTimeout = <T,>(promise: Promise<T>, ms: number): Promise<T> =>
Promise.race([promise, sleep(ms).then((): never => { throw new Error(`timed out after ${ms}ms`); })]);

function getBackendHost(): string {
// Only the Vite dev server is hosted separately from the backend. Built assets are served from
Expand All @@ -74,44 +95,77 @@ function startConnection(): RpcStub<PublicApi> {
return newWebSocketRpcSession<PublicApi>(wsUrl);
}

async function handleBroken(error: any) {
console.warn('RPC connection lost:', error);
const disposeQuietly = (stub: RpcStub<PublicApi>) => {
try { stub[Symbol.dispose](); } catch { /* already broken */ }
};

// A reconnect attempt only becomes `currentStub` after a probe RPC round-trips: capnweb queues
// sends while the socket is still CONNECTING, so an unproven stub looks fine until everything
// pipelined onto it fails at once. Proving first means subscribers hear exactly twice per
// outage — lost, then restored — instead of once per failed attempt.
async function handleBroken(error: unknown) {
if (reconnecting) return; // stale/disposed stub, or recovery already underway
reconnecting = true;

console.warn('RPC connection lost:', error);
isConnectionLost = true;
for (let cb of notifyCurrentStubUpdated) { cb(); }

let timeSinceConnect = Date.now() - lastConnectTime;
if (timeSinceConnect < backoff) {
let waitTime = backoff - timeSinceConnect;
console.warn(`Will try again in ${Math.round(waitTime / 1000)} seconds...`)
await new Promise(resolve => setTimeout(resolve, waitTime));
console.warn(`Retrying connection...`);
backoff = Math.min(backoff * 2, 10000);
} else {
backoff = 1000;
notifySubscribers(); // `currentStub` stays the dead one, so stub-keyed effects don't re-fire

// Fast recovery from one-off blips: skip the first backoff if the dying connection was up a while.
let skipSleep = Date.now() - lastConnectTime >= INITIAL_BACKOFF_MS;
let backoff = INITIAL_BACKOFF_MS;
for (;;) {
if (!skipSleep) {
await sleep(backoff * (0.85 + 0.3 * Math.random())); // jittered against stampedes
backoff = Math.min(backoff * 2, MAX_BACKOFF_MS);
}
skipSleep = false;

const candidate = startConnection();
try {
await withTimeout(candidate.getServerConfig(), RECONNECT_PROBE_TIMEOUT_MS);
} catch (probeError) {
console.debug('Reconnect attempt failed:', probeError);
disposeQuietly(candidate);
continue;
}

candidate.onRpcBroken(handleBroken);
currentStub = candidate;
lastProvenAt = Date.now();
reconnecting = false;
isConnectionLost = false;
console.warn('RPC connection restored.');
notifySubscribers();
return;
}
}

currentStub = startConnection();
currentStub.onRpcBroken(handleBroken);

// Don't clear isConnectionLost here — the new connection hasn't proven
// it works yet. It gets cleared by markConnectionRestored() once the
// app successfully communicates with the backend.
for (let cb of notifyCurrentStubUpdated) {
cb();
// Passive close detection misses sockets killed during laptop sleep or tab throttling, so on
// tab-visible / network-online signals probe the connection instead of letting the user's next
// action hang on a zombie socket.
async function probeOnWake() {
if (reconnecting || probing || Date.now() - lastProvenAt < WAKE_PROBE_MIN_IDLE_MS) return;
probing = true;
const suspect = currentStub;
try {
await withTimeout(suspect.getServerConfig(), WAKE_PROBE_TIMEOUT_MS);
lastProvenAt = Date.now();
} catch (error) {
if (currentStub !== suspect || reconnecting) return; // a real broken event won the race
console.warn('Connection unresponsive after wake:', error);
// Disposal fires onRpcBroken → handleBroken recovers. Its skip-first-backoff path retries
// immediately — right for "the network just came back".
disposeQuietly(suspect);
} finally {
probing = false;
}
}

// Callbacks to call whenever `currentStub` or connection state is updated.
let notifyCurrentStubUpdated: Set<() => void> = new Set();
let isConnectionLost = false;

// Called externally (e.g., by auth) to indicate the connection is alive.
export function markConnectionRestored() {
if (!isConnectionLost) return;
isConnectionLost = false;
for (let cb of notifyCurrentStubUpdated) { cb(); }
}
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'visible') void probeOnWake();
});
window.addEventListener('online', () => void probeOnWake());

// Current stub. handleBroken() will replace this on disconnect.
installWorkshopErrorReporting()
Expand All @@ -130,9 +184,9 @@ function AppWithConnection() {
const [serverConfigError, setServerConfigError] = useState(false);

useEffect(() => {
let cb = () => setRpcState({ stub: currentStub, connectionLost: isConnectionLost });
notifyCurrentStubUpdated.add(cb);
return () => { notifyCurrentStubUpdated.delete(cb); };
const cb = () => setRpcState({ stub: currentStub, connectionLost: isConnectionLost });
subscribers.add(cb);
return () => { subscribers.delete(cb); };
}, []);

// Fetch deployment config once the (re)connected stub is available. Re-fetch on reconnect so a
Expand Down
6 changes: 0 additions & 6 deletions packages/workshop-frontend/src/routes/__root.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ import { TooltipProvider, Toasty } from '@cloudflare/kumo'
import { RpcStub } from 'capnweb'
import { AuthenticatedApi } from '@gadgets/workshop-shared/api'
import { useRpcStub, useConnectionLost } from '../RpcContext'
import { markConnectionRestored } from '../main'
import { useAuth, CF_ACCESS_MODE } from '../useAuth'
import { AuthProvider } from '../AuthContext'
import { FeatureFlagsProvider } from '../FeatureFlagsContext'
Expand All @@ -25,11 +24,6 @@ function RootComponent() {
const { isAuthenticated, authenticatedApi, isLoading, error, logout, login } = useAuth(rpcStub)
const pathname = useRouterState({ select: (s) => s.location.pathname })

// When authenticatedApi becomes available, the connection is proven alive.
useEffect(() => {
if (authenticatedApi) markConnectionRestored()
}, [authenticatedApi])

// Routes that don't require auth (public routes)
const isSignup = pathname === '/signup'
const isBlueprint = pathname.startsWith('/blueprint/')
Expand Down
Loading