Skip to content

Commit 319012e

Browse files
WebSocket Timeout Fix (#671)
Co-authored-by: Copilot <[email protected]>
1 parent 79acd89 commit 319012e

File tree

3 files changed

+88
-34
lines changed

3 files changed

+88
-34
lines changed

.changeset/bright-yaks-pump.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
'@powersync/common': patch
3+
'@powersync/react-native': patch
4+
'@powersync/web': patch
5+
---
6+
7+
Fixed bug where a WebSocket connection timeout could cause an uncaught exception.

demos/react-supabase-todolist/src/app/views/layout.tsx

Lines changed: 41 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
1+
import { LOGIN_ROUTE, SQL_CONSOLE_ROUTE, TODO_LISTS_ROUTE } from '@/app/router';
2+
import { useNavigationPanel } from '@/components/navigation/NavigationPanelContext';
3+
import { useSupabase } from '@/components/providers/SystemProvider';
14
import ChecklistRtlIcon from '@mui/icons-material/ChecklistRtl';
25
import ExitToAppIcon from '@mui/icons-material/ExitToApp';
36
import MenuIcon from '@mui/icons-material/Menu';
@@ -17,16 +20,15 @@ import {
1720
ListItemButton,
1821
ListItemIcon,
1922
ListItemText,
23+
Menu,
24+
MenuItem,
2025
Toolbar,
2126
Typography,
2227
styled
2328
} from '@mui/material';
24-
import React from 'react';
2529
import { usePowerSync, useStatus } from '@powersync/react';
30+
import React from 'react';
2631
import { useNavigate } from 'react-router-dom';
27-
import { useSupabase } from '@/components/providers/SystemProvider';
28-
import { useNavigationPanel } from '@/components/navigation/NavigationPanelContext';
29-
import { LOGIN_ROUTE, SQL_CONSOLE_ROUTE, TODO_LISTS_ROUTE } from '@/app/router';
3032

3133
export default function ViewsLayout({ children }: { children: React.ReactNode }) {
3234
const powerSync = usePowerSync();
@@ -37,6 +39,8 @@ export default function ViewsLayout({ children }: { children: React.ReactNode })
3739
const [openDrawer, setOpenDrawer] = React.useState(false);
3840
const { title } = useNavigationPanel();
3941

42+
const [connectionAnchor, setConnectionAnchor] = React.useState<null | HTMLElement>(null);
43+
4044
const NAVIGATION_ITEMS = React.useMemo(
4145
() => [
4246
{
@@ -72,16 +76,45 @@ export default function ViewsLayout({ children }: { children: React.ReactNode })
7276
color="inherit"
7377
aria-label="menu"
7478
sx={{ mr: 2 }}
75-
onClick={() => setOpenDrawer(!openDrawer)}
76-
>
79+
onClick={() => setOpenDrawer(!openDrawer)}>
7780
<MenuIcon />
7881
</IconButton>
7982
<Box sx={{ flexGrow: 1 }}>
8083
<Typography>{title}</Typography>
8184
</Box>
8285
<NorthIcon sx={{ marginRight: '-10px' }} color={status?.dataFlowStatus.uploading ? 'primary' : 'inherit'} />
8386
<SouthIcon color={status?.dataFlowStatus.downloading ? 'primary' : 'inherit'} />
84-
{status?.connected ? <WifiIcon /> : <SignalWifiOffIcon />}
87+
<Box
88+
sx={{ cursor: 'pointer' }}
89+
onClick={(event) => {
90+
setConnectionAnchor(event.currentTarget);
91+
}}>
92+
{status?.connected ? <WifiIcon /> : <SignalWifiOffIcon />}
93+
</Box>
94+
{/* Allows for manual connection and disconnect for testing purposes */}
95+
<Menu
96+
id="connection-menu"
97+
anchorEl={connectionAnchor}
98+
open={Boolean(connectionAnchor)}
99+
onClose={() => setConnectionAnchor(null)}>
100+
{status?.connected || status?.connecting ? (
101+
<MenuItem
102+
onClick={(event) => {
103+
setConnectionAnchor(null);
104+
powerSync.disconnect();
105+
}}>
106+
Disconnect
107+
</MenuItem>
108+
) : supabase ? (
109+
<MenuItem
110+
onClick={(event) => {
111+
setConnectionAnchor(null);
112+
powerSync.connect(supabase);
113+
}}>
114+
Connect
115+
</MenuItem>
116+
) : null}
117+
</Menu>
85118
</Toolbar>
86119
</S.TopBar>
87120
<Drawer anchor={'left'} open={openDrawer} onClose={() => setOpenDrawer(false)}>
@@ -95,8 +128,7 @@ export default function ViewsLayout({ children }: { children: React.ReactNode })
95128
await item.beforeNavigate?.();
96129
navigate(item.path);
97130
setOpenDrawer(false);
98-
}}
99-
>
131+
}}>
100132
<ListItemIcon>{item.icon()}</ListItemIcon>
101133
<ListItemText primary={item.title} />
102134
</ListItemButton>

packages/common/src/client/sync/stream/AbstractRemote.ts

Lines changed: 40 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,7 @@ import PACKAGE from '../../../../package.json' with { type: 'json' };
77
import { AbortOperation } from '../../../utils/AbortOperation.js';
88
import { DataStream } from '../../../utils/DataStream.js';
99
import { PowerSyncCredentials } from '../../connection/PowerSyncCredentials.js';
10-
import {
11-
StreamingSyncLine,
12-
StreamingSyncLineOrCrudUploadComplete,
13-
StreamingSyncRequest
14-
} from './streaming-sync-types.js';
10+
import { StreamingSyncRequest } from './streaming-sync-types.js';
1511
import { WebsocketClientTransport } from './WebsocketClientTransport.js';
1612

1713
export type BSONImplementation = typeof BSON;
@@ -305,6 +301,27 @@ export abstract class AbstractRemote {
305301
// automatically as a header.
306302
const userAgent = this.getUserAgent();
307303

304+
const stream = new DataStream<T, Uint8Array>({
305+
logger: this.logger,
306+
pressure: {
307+
lowWaterMark: SYNC_QUEUE_REQUEST_LOW_WATER
308+
},
309+
mapLine: map
310+
});
311+
312+
// Handle upstream abort
313+
if (options.abortSignal?.aborted) {
314+
throw new AbortOperation('Connection request aborted');
315+
} else {
316+
options.abortSignal?.addEventListener(
317+
'abort',
318+
() => {
319+
stream.close();
320+
},
321+
{ once: true }
322+
);
323+
}
324+
308325
let keepAliveTimeout: any;
309326
const resetTimeout = () => {
310327
clearTimeout(keepAliveTimeout);
@@ -315,15 +332,28 @@ export abstract class AbstractRemote {
315332
};
316333
resetTimeout();
317334

335+
// Typescript complains about this being `never` if it's not assigned here.
336+
// This is assigned in `wsCreator`.
337+
let disposeSocketConnectionTimeout = () => {};
338+
318339
const url = this.options.socketUrlTransformer(request.url);
319340
const connector = new RSocketConnector({
320341
transport: new WebsocketClientTransport({
321342
url,
322343
wsCreator: (url) => {
323344
const socket = this.createSocket(url);
345+
disposeSocketConnectionTimeout = stream.registerListener({
346+
closed: () => {
347+
// Allow closing the underlying WebSocket if the stream was closed before the
348+
// RSocket connect completed. This should effectively abort the request.
349+
socket.close();
350+
}
351+
});
352+
324353
socket.addEventListener('message', (event) => {
325354
resetTimeout();
326355
});
356+
327357
return socket;
328358
}
329359
}),
@@ -345,22 +375,19 @@ export abstract class AbstractRemote {
345375
let rsocket: RSocket;
346376
try {
347377
rsocket = await connector.connect();
378+
// The connection is established, we no longer need to monitor the initial timeout
379+
disposeSocketConnectionTimeout();
348380
} catch (ex) {
349381
this.logger.error(`Failed to connect WebSocket`, ex);
350382
clearTimeout(keepAliveTimeout);
383+
if (!stream.closed) {
384+
await stream.close();
385+
}
351386
throw ex;
352387
}
353388

354389
resetTimeout();
355390

356-
const stream = new DataStream<T, Uint8Array>({
357-
logger: this.logger,
358-
pressure: {
359-
lowWaterMark: SYNC_QUEUE_REQUEST_LOW_WATER
360-
},
361-
mapLine: map
362-
});
363-
364391
let socketIsClosed = false;
365392
const closeSocket = () => {
366393
clearTimeout(keepAliveTimeout);
@@ -455,18 +482,6 @@ export abstract class AbstractRemote {
455482
}
456483
});
457484

458-
/**
459-
* Handle abort operations here.
460-
* Unfortunately cannot insert them into the connection.
461-
*/
462-
if (options.abortSignal?.aborted) {
463-
stream.close();
464-
} else {
465-
options.abortSignal?.addEventListener('abort', () => {
466-
stream.close();
467-
});
468-
}
469-
470485
return stream;
471486
}
472487

0 commit comments

Comments
 (0)