diff --git a/.changeset/bright-yaks-pump.md b/.changeset/bright-yaks-pump.md new file mode 100644 index 000000000..de11da611 --- /dev/null +++ b/.changeset/bright-yaks-pump.md @@ -0,0 +1,7 @@ +--- +'@powersync/common': patch +'@powersync/react-native': patch +'@powersync/web': patch +--- + +Fixed bug where a WebSocket connection timeout could cause an uncaught exception. diff --git a/demos/react-supabase-todolist/src/app/views/layout.tsx b/demos/react-supabase-todolist/src/app/views/layout.tsx index cb6016a54..f3a0d9404 100644 --- a/demos/react-supabase-todolist/src/app/views/layout.tsx +++ b/demos/react-supabase-todolist/src/app/views/layout.tsx @@ -1,3 +1,6 @@ +import { LOGIN_ROUTE, SQL_CONSOLE_ROUTE, TODO_LISTS_ROUTE } from '@/app/router'; +import { useNavigationPanel } from '@/components/navigation/NavigationPanelContext'; +import { useSupabase } from '@/components/providers/SystemProvider'; import ChecklistRtlIcon from '@mui/icons-material/ChecklistRtl'; import ExitToAppIcon from '@mui/icons-material/ExitToApp'; import MenuIcon from '@mui/icons-material/Menu'; @@ -17,16 +20,15 @@ import { ListItemButton, ListItemIcon, ListItemText, + Menu, + MenuItem, Toolbar, Typography, styled } from '@mui/material'; -import React from 'react'; import { usePowerSync, useStatus } from '@powersync/react'; +import React from 'react'; import { useNavigate } from 'react-router-dom'; -import { useSupabase } from '@/components/providers/SystemProvider'; -import { useNavigationPanel } from '@/components/navigation/NavigationPanelContext'; -import { LOGIN_ROUTE, SQL_CONSOLE_ROUTE, TODO_LISTS_ROUTE } from '@/app/router'; export default function ViewsLayout({ children }: { children: React.ReactNode }) { const powerSync = usePowerSync(); @@ -37,6 +39,8 @@ export default function ViewsLayout({ children }: { children: React.ReactNode }) const [openDrawer, setOpenDrawer] = React.useState(false); const { title } = useNavigationPanel(); + const [connectionAnchor, setConnectionAnchor] = React.useState(null); + const NAVIGATION_ITEMS = React.useMemo( () => [ { @@ -72,8 +76,7 @@ export default function ViewsLayout({ children }: { children: React.ReactNode }) color="inherit" aria-label="menu" sx={{ mr: 2 }} - onClick={() => setOpenDrawer(!openDrawer)} - > + onClick={() => setOpenDrawer(!openDrawer)}> @@ -81,7 +84,37 @@ export default function ViewsLayout({ children }: { children: React.ReactNode }) - {status?.connected ? : } + { + setConnectionAnchor(event.currentTarget); + }}> + {status?.connected ? : } + + {/* Allows for manual connection and disconnect for testing purposes */} + setConnectionAnchor(null)}> + {status?.connected || status?.connecting ? ( + { + setConnectionAnchor(null); + powerSync.disconnect(); + }}> + Disconnect + + ) : supabase ? ( + { + setConnectionAnchor(null); + powerSync.connect(supabase); + }}> + Connect + + ) : null} + setOpenDrawer(false)}> @@ -95,8 +128,7 @@ export default function ViewsLayout({ children }: { children: React.ReactNode }) await item.beforeNavigate?.(); navigate(item.path); setOpenDrawer(false); - }} - > + }}> {item.icon()} diff --git a/packages/common/src/client/sync/stream/AbstractRemote.ts b/packages/common/src/client/sync/stream/AbstractRemote.ts index f0de66d00..81da1ac8e 100644 --- a/packages/common/src/client/sync/stream/AbstractRemote.ts +++ b/packages/common/src/client/sync/stream/AbstractRemote.ts @@ -7,11 +7,7 @@ import PACKAGE from '../../../../package.json' with { type: 'json' }; import { AbortOperation } from '../../../utils/AbortOperation.js'; import { DataStream } from '../../../utils/DataStream.js'; import { PowerSyncCredentials } from '../../connection/PowerSyncCredentials.js'; -import { - StreamingSyncLine, - StreamingSyncLineOrCrudUploadComplete, - StreamingSyncRequest -} from './streaming-sync-types.js'; +import { StreamingSyncRequest } from './streaming-sync-types.js'; import { WebsocketClientTransport } from './WebsocketClientTransport.js'; export type BSONImplementation = typeof BSON; @@ -305,6 +301,27 @@ export abstract class AbstractRemote { // automatically as a header. const userAgent = this.getUserAgent(); + const stream = new DataStream({ + logger: this.logger, + pressure: { + lowWaterMark: SYNC_QUEUE_REQUEST_LOW_WATER + }, + mapLine: map + }); + + // Handle upstream abort + if (options.abortSignal?.aborted) { + throw new AbortOperation('Connection request aborted'); + } else { + options.abortSignal?.addEventListener( + 'abort', + () => { + stream.close(); + }, + { once: true } + ); + } + let keepAliveTimeout: any; const resetTimeout = () => { clearTimeout(keepAliveTimeout); @@ -315,15 +332,28 @@ export abstract class AbstractRemote { }; resetTimeout(); + // Typescript complains about this being `never` if it's not assigned here. + // This is assigned in `wsCreator`. + let disposeSocketConnectionTimeout = () => {}; + const url = this.options.socketUrlTransformer(request.url); const connector = new RSocketConnector({ transport: new WebsocketClientTransport({ url, wsCreator: (url) => { const socket = this.createSocket(url); + disposeSocketConnectionTimeout = stream.registerListener({ + closed: () => { + // Allow closing the underlying WebSocket if the stream was closed before the + // RSocket connect completed. This should effectively abort the request. + socket.close(); + } + }); + socket.addEventListener('message', (event) => { resetTimeout(); }); + return socket; } }), @@ -345,22 +375,19 @@ export abstract class AbstractRemote { let rsocket: RSocket; try { rsocket = await connector.connect(); + // The connection is established, we no longer need to monitor the initial timeout + disposeSocketConnectionTimeout(); } catch (ex) { this.logger.error(`Failed to connect WebSocket`, ex); clearTimeout(keepAliveTimeout); + if (!stream.closed) { + await stream.close(); + } throw ex; } resetTimeout(); - const stream = new DataStream({ - logger: this.logger, - pressure: { - lowWaterMark: SYNC_QUEUE_REQUEST_LOW_WATER - }, - mapLine: map - }); - let socketIsClosed = false; const closeSocket = () => { clearTimeout(keepAliveTimeout); @@ -455,18 +482,6 @@ export abstract class AbstractRemote { } }); - /** - * Handle abort operations here. - * Unfortunately cannot insert them into the connection. - */ - if (options.abortSignal?.aborted) { - stream.close(); - } else { - options.abortSignal?.addEventListener('abort', () => { - stream.close(); - }); - } - return stream; }