Skip to content

Commit 1bd46d0

Browse files
committed
fix(ws): fail in-flight queries when the connection drops or times out
On the WebSocket transport a query waits for the socket.io ack of 'GET /v2/weblite/sql'. When the socket disconnected mid-query the ack never fired and the error was only sent to the connect callback, which had already run, so the query promise stayed pending forever. - track the commands waiting for an ack and fail them with the disconnect/connection error when the socket goes away or close() is called - report the connect callback only once, for the outcome of the connection attempt - honour config.timeout on the WebSocket transport too Fixes #283
1 parent bb3026e commit 1bd46d0

4 files changed

Lines changed: 179 additions & 15 deletions

File tree

‎package.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@sqlitecloud/drivers",
3-
"version": "1.0.982",
3+
"version": "1.0.1022",
44
"description": "SQLiteCloud drivers for Typescript/Javascript in edge, web and node clients",
55
"main": "./lib/index.js",
66
"types": "./lib/index.d.ts",

‎src/drivers/connection-ws.ts‎

Lines changed: 56 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,9 @@ export class SQLiteCloudWebsocketConnection extends SQLiteCloudConnection {
7878
/** Socket.io used to communicated with SQLiteCloud server */
7979
private socket?: Socket
8080

81+
/** Commands sent to the gateway that are still waiting for their ack, failed if the socket goes away */
82+
private pendingCommands?: Set<(error: Error) => void>
83+
8184
/** True if connection is open */
8285
get connected(): boolean {
8386
return !!(this.socket && this.socket?.connected)
@@ -116,24 +119,34 @@ export class SQLiteCloudWebsocketConnection extends SQLiteCloudConnection {
116119
}
117120
this.socket = io(gatewayUrl, ioOpts)
118121

122+
// the connect callback reports the outcome of the connection attempt and is called only once,
123+
// errors that happen later are reported to the commands that are waiting for a response
124+
let connectReported = false
125+
const reportConnect = (error: Error | null) => {
126+
if (!connectReported) {
127+
connectReported = true
128+
callback?.call(this, error)
129+
}
130+
}
131+
const fail = (error: Error) => {
132+
this.closeWithError(error)
133+
reportConnect(error)
134+
}
135+
119136
this.socket.on('connect', () => {
120-
callback?.call(this, null)
137+
reportConnect(null)
121138
})
122139

123140
this.socket.on('disconnect', (reason, description) => {
124-
this.close()
125-
callback?.call(
126-
this,
141+
fail(
127142
(reason === 'parse error' && getAttachmentLimitError(description, websocketMaxAttachments)) ||
128143
new SQLiteCloudError('Disconnected', { errorCode: 'ERR_CONNECTION_ENDED', cause: reason })
129144
)
130145
})
131146

132147
this.socket.on('connect_error', (error: any) => {
133-
this.close()
134148
if (error?.message === 'parse error' || error?.cause === 'parse error') {
135-
callback?.call(
136-
this,
149+
fail(
137150
getAttachmentLimitError(error?.description || error?.data || error?.cause, websocketMaxAttachments) ||
138151
new SQLiteCloudError('Connection error', { errorCode: 'ERR_CONNECTION_ERROR', cause: error })
139152
)
@@ -148,12 +161,11 @@ export class SQLiteCloudWebsocketConnection extends SQLiteCloudConnection {
148161
message = error.context.responseText
149162
}
150163
}
151-
callback?.call(this, new SQLiteCloudError(message, { errorCode: 'ERR_CONNECTION_ERROR' }))
164+
fail(new SQLiteCloudError(message, { errorCode: 'ERR_CONNECTION_ERROR' }))
152165
})
153166

154167
this.socket.on('error', (error: Error) => {
155-
this.close()
156-
callback?.call(this, new SQLiteCloudError('Connection error', { errorCode: 'ERR_CONNECTION_ERROR', cause: error }))
168+
fail(new SQLiteCloudError('Connection error', { errorCode: 'ERR_CONNECTION_ERROR', cause: error }))
157169
})
158170
}
159171
} catch (error) {
@@ -175,6 +187,28 @@ export class SQLiteCloudWebsocketConnection extends SQLiteCloudConnection {
175187
commands = { query: commands }
176188
}
177189

190+
// the callback is called exactly once: with the gateway response, on timeout or when the connection drops
191+
let settled = false
192+
let timeout: ReturnType<typeof setTimeout> | undefined
193+
const finish = (error: Error | null, results?: any) => {
194+
if (settled) return
195+
settled = true
196+
clearTimeout(timeout)
197+
this.pendingCommands?.delete(fail)
198+
callback?.call(this, error, results)
199+
}
200+
const fail = (error: Error) => finish(error)
201+
202+
this.pendingCommands ||= new Set()
203+
this.pendingCommands.add(fail)
204+
205+
const timeoutMs = this.config?.timeout ?? 0
206+
if (timeoutMs > 0) {
207+
timeout = setTimeout(() => {
208+
finish(new SQLiteCloudError(`Request timed out after ${timeoutMs} ms`, { errorCode: 'ERR_CONNECTION_TIMEOUT' }))
209+
}, timeoutMs)
210+
}
211+
178212
this.socket.emit(
179213
'GET /v2/weblite/sql',
180214
{
@@ -194,7 +228,7 @@ export class SQLiteCloudWebsocketConnection extends SQLiteCloudConnection {
194228
// for the `errors[]` shape; fall back to top-level for the legacy `error` shape.
195229
const errorFields = (gatewayError.meta && typeof gatewayError.meta === 'object') ? gatewayError.meta : gatewayError
196230
const error = new SQLiteCloudError(gatewayError.detail || gatewayError.message || 'Gateway error', { ...errorFields })
197-
callback?.call(this, error)
231+
finish(error)
198232
} else {
199233
const { metadata } = response
200234
const blobTransferFormat = getResponseBlobTransferFormat(response)
@@ -208,11 +242,11 @@ export class SQLiteCloudWebsocketConnection extends SQLiteCloudConnection {
208242
console.assert(Array.isArray(data), 'SQLiteCloudWebsocketConnection.transportCommands - data is not an array')
209243
// we can recreate a SQLiteCloudRowset from the response which we know to be an array of arrays
210244
const rowset = new SQLiteCloudRowset(metadata, data.flat())
211-
callback?.call(this, null, rowset)
245+
finish(null, rowset)
212246
return
213247
}
214248
}
215-
callback?.call(this, null, data)
249+
finish(null, data)
216250
}
217251
}
218252
)
@@ -222,6 +256,11 @@ export class SQLiteCloudWebsocketConnection extends SQLiteCloudConnection {
222256

223257
/** Disconnect socket.io from server */
224258
public close(): this {
259+
return this.closeWithError(new SQLiteCloudError('Connection closed', { errorCode: 'ERR_CONNECTION_CLOSED' }))
260+
}
261+
262+
/** Disconnect socket.io from server and fail the commands still waiting for a response with the given error */
263+
private closeWithError(error: Error): this {
225264
console.assert(this.socket !== null, 'SQLiteCloudWebsocketConnection.close - connection already closed')
226265
if (this.socket) {
227266
this.socket?.removeAllListeners()
@@ -230,6 +269,10 @@ export class SQLiteCloudWebsocketConnection extends SQLiteCloudConnection {
230269
}
231270

232271
this.operations.clear()
272+
273+
const pendingCommands = this.pendingCommands
274+
this.pendingCommands = undefined
275+
pendingCommands?.forEach(fail => fail(error))
233276
return this
234277
}
235278
}

‎src/drivers/types.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,7 @@ export interface SQLiteCloudConfig {
6868
/** Connect using plain TCP port, without TLS encryption, NOT RECOMMENDED, TEST ONLY */
6969
insecure?: boolean
7070

71-
/** Optional query timeout passed directly to TLS socket */
71+
/** Optional query timeout in milliseconds, applies to both TLS and WebSocket connections */
7272
timeout?: number
7373
/** Name of database to open */
7474
database?: string

‎test/connection-ws-unit.test.ts‎

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -259,4 +259,125 @@ describe('websocket transport helpers', () => {
259259
})
260260
expect((callback.mock.calls[0]?.[0] as Error).message).toContain('websocketMaxAttachments')
261261
})
262+
263+
describe('in-flight commands', () => {
264+
function connectMockedSocket(config: Record<string, any> = {}) {
265+
const handlers: Record<string, (...args: any[]) => void> = {}
266+
const socket = {
267+
connected: true,
268+
on: jest.fn((event: string, handler: (...args: any[]) => void) => {
269+
handlers[event] = handler
270+
}),
271+
emit: jest.fn(),
272+
removeAllListeners: jest.fn(),
273+
close: jest.fn()
274+
}
275+
const mockedIo = io as jest.MockedFunction<typeof io>
276+
mockedIo.mockReturnValue(socket as any)
277+
278+
const connectCallback = jest.fn()
279+
const connection = Object.create(SQLiteCloudWebsocketConnection.prototype) as any
280+
connection.operations = { clear: jest.fn() }
281+
connection.connectTransport(
282+
{ connectionstring: 'sqlitecloud://host.sqlite.cloud/database?apikey=secret', host: 'host.sqlite.cloud', apikey: 'secret', ...config },
283+
connectCallback
284+
)
285+
handlers.connect?.()
286+
287+
return { connection, socket, handlers, connectCallback }
288+
}
289+
290+
afterEach(() => {
291+
jest.useRealTimers()
292+
})
293+
294+
it('should fail the command in flight when the connection drops', () => {
295+
const { connection, socket, handlers, connectCallback } = connectMockedSocket()
296+
const commandCallback = jest.fn()
297+
298+
connection.transportCommands('SELECT 1', commandCallback)
299+
expect(socket.emit).toHaveBeenCalledTimes(1)
300+
expect(commandCallback).not.toHaveBeenCalled()
301+
302+
handlers.disconnect?.('transport error')
303+
304+
expect(commandCallback).toHaveBeenCalledTimes(1)
305+
expect(commandCallback.mock.calls[0]?.[0]).toMatchObject({ message: 'Disconnected', errorCode: 'ERR_CONNECTION_ENDED', cause: 'transport error' })
306+
expect(socket.close).toHaveBeenCalled()
307+
expect(connection.connected).toBe(false)
308+
309+
// the connect callback already reported a successful connection and must not be called again
310+
expect(connectCallback).toHaveBeenCalledTimes(1)
311+
expect(connectCallback).toHaveBeenCalledWith(null)
312+
})
313+
314+
it('should report a disconnection before connect to the connect callback', () => {
315+
const handlers: Record<string, (...args: any[]) => void> = {}
316+
const socket = {
317+
connected: false,
318+
on: jest.fn((event: string, handler: (...args: any[]) => void) => {
319+
handlers[event] = handler
320+
}),
321+
removeAllListeners: jest.fn(),
322+
close: jest.fn()
323+
}
324+
;(io as jest.MockedFunction<typeof io>).mockReturnValue(socket as any)
325+
326+
const connectCallback = jest.fn()
327+
const connection = Object.create(SQLiteCloudWebsocketConnection.prototype) as any
328+
connection.operations = { clear: jest.fn() }
329+
connection.connectTransport({ connectionstring: 'sqlitecloud://host.sqlite.cloud/database', host: 'host.sqlite.cloud' }, connectCallback)
330+
331+
handlers.disconnect?.('transport close')
332+
333+
expect(connectCallback).toHaveBeenCalledTimes(1)
334+
expect(connectCallback.mock.calls[0]?.[0]).toMatchObject({ errorCode: 'ERR_CONNECTION_ENDED' })
335+
})
336+
337+
it('should fail the command in flight when the connection is closed', () => {
338+
const { connection } = connectMockedSocket()
339+
const commandCallback = jest.fn()
340+
341+
connection.transportCommands('SELECT 1', commandCallback)
342+
connection.close()
343+
344+
expect(commandCallback).toHaveBeenCalledTimes(1)
345+
expect(commandCallback.mock.calls[0]?.[0]).toMatchObject({ errorCode: 'ERR_CONNECTION_CLOSED' })
346+
})
347+
348+
it('should fail the command in flight after the configured timeout and ignore a late response', () => {
349+
jest.useFakeTimers()
350+
const { connection, socket } = connectMockedSocket({ timeout: 1000 })
351+
const commandCallback = jest.fn()
352+
353+
connection.transportCommands('SELECT 1', commandCallback)
354+
355+
jest.advanceTimersByTime(999)
356+
expect(commandCallback).not.toHaveBeenCalled()
357+
358+
jest.advanceTimersByTime(1)
359+
expect(commandCallback).toHaveBeenCalledTimes(1)
360+
expect(commandCallback.mock.calls[0]?.[0]).toMatchObject({ errorCode: 'ERR_CONNECTION_TIMEOUT' })
361+
362+
const ack = socket.emit.mock.calls[0]?.[2] as (response: any) => void
363+
ack({ data: 1 })
364+
expect(commandCallback).toHaveBeenCalledTimes(1)
365+
})
366+
367+
it('should not fail a completed command when the connection drops later', () => {
368+
jest.useFakeTimers()
369+
const { connection, socket, handlers } = connectMockedSocket({ timeout: 1000 })
370+
const commandCallback = jest.fn()
371+
372+
connection.transportCommands('SELECT 1', commandCallback)
373+
const ack = socket.emit.mock.calls[0]?.[2] as (response: any) => void
374+
ack({ data: 1 })
375+
376+
handlers.disconnect?.('transport close')
377+
jest.advanceTimersByTime(1000)
378+
379+
expect(commandCallback).toHaveBeenCalledTimes(1)
380+
expect(commandCallback).toHaveBeenCalledWith(null, 1)
381+
})
382+
})
262383
})

0 commit comments

Comments
 (0)