From aa73af57e8c54b140922b383f04c29e2f8c4e5ab Mon Sep 17 00:00:00 2001 From: OneCalmCloud <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 11 May 2026 20:17:04 +0800 Subject: [PATCH 1/4] fix(pglite-socket): handle PostgreSQL SSLRequest before startup --- .changeset/fix-pglite-socket-ssl-request.md | 5 ++ packages/pglite-socket/src/index.ts | 18 +++++ .../pglite-socket/tests/ssl-request.test.ts | 76 +++++++++++++++++++ 3 files changed, 99 insertions(+) create mode 100644 .changeset/fix-pglite-socket-ssl-request.md create mode 100644 packages/pglite-socket/tests/ssl-request.test.ts diff --git a/.changeset/fix-pglite-socket-ssl-request.md b/.changeset/fix-pglite-socket-ssl-request.md new file mode 100644 index 000000000..3d6721e03 --- /dev/null +++ b/.changeset/fix-pglite-socket-ssl-request.md @@ -0,0 +1,5 @@ +--- +'@electric-sql/pglite-socket': patch +--- + +Reply `N` to the PostgreSQL `SSLRequest` packet when SSL is not supported, preventing mis-parsing and hung connections from clients that probe TLS before `StartupMessage` (e.g. Navicat, libpq defaults). diff --git a/packages/pglite-socket/src/index.ts b/packages/pglite-socket/src/index.ts index 8dfd4ce01..cdb3069da 100644 --- a/packages/pglite-socket/src/index.ts +++ b/packages/pglite-socket/src/index.ts @@ -343,6 +343,24 @@ export class PGLiteSocketHandler extends EventTarget { let totalProcessed = 0 while (this.messageBuffer.length > 0) { + // SSLRequest: length 8, second int 80877103 (PostgreSQL protocol). Many + // clients send this before StartupMessage. Server must reply 'N' when SSL + // is not supported (pglite-socket has no TLS). Without handling it here, + // the packet is mis-parsed as a typed frontend message and the connection + // stalls (e.g. Navicat “test connection” never completing). + if (this.messageBuffer.length >= 8) { + const len = this.messageBuffer.readInt32BE(0) + const code = this.messageBuffer.readInt32BE(4) + if (len === 8 && code === 80877103) { + this.log('handleData: SSLRequest, replying N (SSL not supported)') + if (this.socket?.writable) { + this.socket.write(Buffer.from('N')) + } + this.messageBuffer = this.messageBuffer.slice(8) + continue + } + } + // Determine message length let messageLength = 0 let isComplete = false diff --git a/packages/pglite-socket/tests/ssl-request.test.ts b/packages/pglite-socket/tests/ssl-request.test.ts new file mode 100644 index 000000000..2238083f9 --- /dev/null +++ b/packages/pglite-socket/tests/ssl-request.test.ts @@ -0,0 +1,76 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { PGLiteSocketHandler } from '../src' + +/** Second int32 in PostgreSQL SSLRequest packet */ +const PG_PROTOCOL_SSL_REQUEST_CODE = 80877103 + +function createNetSocketStub() { + const eventHandlers: Record void>> = {} + const socket = { + writable: true, + remoteAddress: '127.0.0.1', + remotePort: 12345, + setNoDelay: vi.fn(), + write: vi.fn(), + removeAllListeners: vi.fn(), + end: vi.fn(), + destroy: vi.fn(), + on: vi.fn((event: string, callback: (data?: unknown) => void) => { + if (!eventHandlers[event]) eventHandlers[event] = [] + eventHandlers[event].push(callback) + return socket + }), + emit(event: string, data?: unknown) { + eventHandlers[event]?.forEach((h) => h(data)) + }, + } + return socket as any +} + +function createQueryQueueStub() { + return { + enqueue: vi.fn().mockResolvedValue(0), + clearQueueForHandler: vi.fn(), + clearTransactionIfNeeded: vi.fn().mockResolvedValue(undefined), + getQueueLength: vi.fn().mockReturnValue(0), + } +} + +async function flushEventLoop(): Promise { + await new Promise((r) => setImmediate(r)) + await new Promise((r) => setImmediate(r)) +} + +describe('PGLiteSocketHandler SSL negotiation', () => { + let handler: PGLiteSocketHandler + let socketStub: ReturnType + let queryQueueStub: ReturnType + + beforeEach(() => { + queryQueueStub = createQueryQueueStub() + handler = new PGLiteSocketHandler({ + queryQueue: queryQueueStub as any, + }) + socketStub = createNetSocketStub() + }) + + afterEach(async () => { + if (handler?.isAttached) { + await handler.detach(true) + } + }) + + it("consumes SSLRequest (8 bytes) and writes 'N' without queueing PGlite protocol", async () => { + await handler.attach(socketStub) + + const sslRequest = Buffer.alloc(8) + sslRequest.writeInt32BE(8, 0) + sslRequest.writeInt32BE(PG_PROTOCOL_SSL_REQUEST_CODE, 4) + socketStub.emit('data', sslRequest) + + await flushEventLoop() + + expect(socketStub.write).toHaveBeenCalledWith(Buffer.from('N')) + expect(queryQueueStub.enqueue).not.toHaveBeenCalled() + }) +}) From 5b489fb005d5bd61e40f0267866c093fc5cbe79f Mon Sep 17 00:00:00 2001 From: OneCalmCloud Date: Mon, 11 May 2026 20:23:03 +0800 Subject: [PATCH 2/4] docs(pglite-socket): cite protocol docs for SSLRequest handling --- .changeset/fix-pglite-socket-ssl-request.md | 2 +- packages/pglite-socket/src/index.ts | 12 +++++++----- packages/pglite-socket/tests/ssl-request.test.ts | 4 ++-- 3 files changed, 10 insertions(+), 8 deletions(-) diff --git a/.changeset/fix-pglite-socket-ssl-request.md b/.changeset/fix-pglite-socket-ssl-request.md index 3d6721e03..60caf7535 100644 --- a/.changeset/fix-pglite-socket-ssl-request.md +++ b/.changeset/fix-pglite-socket-ssl-request.md @@ -2,4 +2,4 @@ '@electric-sql/pglite-socket': patch --- -Reply `N` to the PostgreSQL `SSLRequest` packet when SSL is not supported, preventing mis-parsing and hung connections from clients that probe TLS before `StartupMessage` (e.g. Navicat, libpq defaults). +Handle the `SSLRequest` startup packet per the PostgreSQL wire protocol: when SSL is not available, respond with `N` so the client may continue with a cleartext `StartupMessage`. See https://www.postgresql.org/docs/current/protocol-message-formats.html . diff --git a/packages/pglite-socket/src/index.ts b/packages/pglite-socket/src/index.ts index cdb3069da..84d80f7c7 100644 --- a/packages/pglite-socket/src/index.ts +++ b/packages/pglite-socket/src/index.ts @@ -343,11 +343,13 @@ export class PGLiteSocketHandler extends EventTarget { let totalProcessed = 0 while (this.messageBuffer.length > 0) { - // SSLRequest: length 8, second int 80877103 (PostgreSQL protocol). Many - // clients send this before StartupMessage. Server must reply 'N' when SSL - // is not supported (pglite-socket has no TLS). Without handling it here, - // the packet is mis-parsed as a typed frontend message and the connection - // stalls (e.g. Navicat “test connection” never completing). + // SSLRequest: Int32 length 8 then Int32(80877103). Documented alongside + // other protocol message layouts in PostgreSQL docs (see + // https://www.postgresql.org/docs/current/protocol-message-formats.html ). + // The backend must send 'S' or 'N' before the client sends StartupMessage; + // pglite-socket has no TLS, so we reply 'N'. Without this branch the + // eight bytes are mis-parsed as a typed frontend message and the reader + // waits indefinitely for a complete packet. if (this.messageBuffer.length >= 8) { const len = this.messageBuffer.readInt32BE(0) const code = this.messageBuffer.readInt32BE(4) diff --git a/packages/pglite-socket/tests/ssl-request.test.ts b/packages/pglite-socket/tests/ssl-request.test.ts index 2238083f9..3bb6e6960 100644 --- a/packages/pglite-socket/tests/ssl-request.test.ts +++ b/packages/pglite-socket/tests/ssl-request.test.ts @@ -1,7 +1,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' import { PGLiteSocketHandler } from '../src' -/** Second int32 in PostgreSQL SSLRequest packet */ +/** Second Int32 of SSLRequest — https://www.postgresql.org/docs/current/protocol-message-formats.html */ const PG_PROTOCOL_SSL_REQUEST_CODE = 80877103 function createNetSocketStub() { @@ -41,7 +41,7 @@ async function flushEventLoop(): Promise { await new Promise((r) => setImmediate(r)) } -describe('PGLiteSocketHandler SSL negotiation', () => { +describe('PGLiteSocketHandler PostgreSQL SSLRequest (protocol-message-formats)', () => { let handler: PGLiteSocketHandler let socketStub: ReturnType let queryQueueStub: ReturnType From 24715aede2e79f92c10f97ee768d639881efba44 Mon Sep 17 00:00:00 2001 From: OneCalmCloud Date: Mon, 11 May 2026 20:34:57 +0800 Subject: [PATCH 3/4] docs(pglite-socket): note DBeaver SSLRequest handshake scenario --- .changeset/fix-pglite-socket-ssl-request.md | 2 +- packages/pglite-socket/src/index.ts | 9 ++++++--- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/.changeset/fix-pglite-socket-ssl-request.md b/.changeset/fix-pglite-socket-ssl-request.md index 60caf7535..958380139 100644 --- a/.changeset/fix-pglite-socket-ssl-request.md +++ b/.changeset/fix-pglite-socket-ssl-request.md @@ -2,4 +2,4 @@ '@electric-sql/pglite-socket': patch --- -Handle the `SSLRequest` startup packet per the PostgreSQL wire protocol: when SSL is not available, respond with `N` so the client may continue with a cleartext `StartupMessage`. See https://www.postgresql.org/docs/current/protocol-message-formats.html . +Handle the `SSLRequest` startup packet per the PostgreSQL wire protocol: when SSL is not available, respond with `N` so the client may continue with a cleartext `StartupMessage`. Improves interoperability with JDBC clients such as DBeaver that probe TLS first without requiring manual SSL mode tweaks. See https://www.postgresql.org/docs/current/protocol-message-formats.html . diff --git a/packages/pglite-socket/src/index.ts b/packages/pglite-socket/src/index.ts index 84d80f7c7..bf5d0b8f1 100644 --- a/packages/pglite-socket/src/index.ts +++ b/packages/pglite-socket/src/index.ts @@ -347,9 +347,12 @@ export class PGLiteSocketHandler extends EventTarget { // other protocol message layouts in PostgreSQL docs (see // https://www.postgresql.org/docs/current/protocol-message-formats.html ). // The backend must send 'S' or 'N' before the client sends StartupMessage; - // pglite-socket has no TLS, so we reply 'N'. Without this branch the - // eight bytes are mis-parsed as a typed frontend message and the reader - // waits indefinitely for a complete packet. + // pglite-socket has no TLS, so we reply 'N'. JDBC stacks used by tools + // such as DBeaver typically send SSLRequest first; answering 'N' completes + // that negotiation so StartupMessage follows over cleartext without asking + // users to manually disable SSL mode. Without this branch the eight bytes + // are mis-parsed as a typed frontend message and the reader waits + // indefinitely for a complete packet. if (this.messageBuffer.length >= 8) { const len = this.messageBuffer.readInt32BE(0) const code = this.messageBuffer.readInt32BE(4) From 0d2a3a544a83072caf91d9dee658d3fc2d45f472 Mon Sep 17 00:00:00 2001 From: OneCalmCloud Date: Mon, 11 May 2026 20:47:08 +0800 Subject: [PATCH 4/4] docs(pglite-socket): refine SSLRequest comment; drop redundant debug log --- packages/pglite-socket/src/index.ts | 16 +++++----------- 1 file changed, 5 insertions(+), 11 deletions(-) diff --git a/packages/pglite-socket/src/index.ts b/packages/pglite-socket/src/index.ts index bf5d0b8f1..aeae7e26b 100644 --- a/packages/pglite-socket/src/index.ts +++ b/packages/pglite-socket/src/index.ts @@ -343,21 +343,15 @@ export class PGLiteSocketHandler extends EventTarget { let totalProcessed = 0 while (this.messageBuffer.length > 0) { - // SSLRequest: Int32 length 8 then Int32(80877103). Documented alongside - // other protocol message layouts in PostgreSQL docs (see - // https://www.postgresql.org/docs/current/protocol-message-formats.html ). - // The backend must send 'S' or 'N' before the client sends StartupMessage; - // pglite-socket has no TLS, so we reply 'N'. JDBC stacks used by tools - // such as DBeaver typically send SSLRequest first; answering 'N' completes - // that negotiation so StartupMessage follows over cleartext without asking - // users to manually disable SSL mode. Without this branch the eight bytes - // are mis-parsed as a typed frontend message and the reader waits - // indefinitely for a complete packet. + // SSLRequest: first Int32 is length (8); second Int32 is fixed 80877103. + // This and other frontend/backend message layouts are specified in PostgreSQL docs: + // https://www.postgresql.org/docs/current/protocol-message-formats.html + // Rules: server must reply 'S' or 'N' before the client sends StartupMessage. + // pglite-socket has no TLS/SSL, so always 'N' (decline SSL). if (this.messageBuffer.length >= 8) { const len = this.messageBuffer.readInt32BE(0) const code = this.messageBuffer.readInt32BE(4) if (len === 8 && code === 80877103) { - this.log('handleData: SSLRequest, replying N (SSL not supported)') if (this.socket?.writable) { this.socket.write(Buffer.from('N')) }