From 2c4f6db0ac15ef0f8500c5d123471dc51dcedc45 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Eray=20Hano=C4=9Flu?= Date: Wed, 9 Sep 2026 20:13:17 +0300 Subject: [PATCH 1/7] fix: correctly parse NegotiateProtocolVersion and expose it on Connection parseNegotiateProtocolVersion() read the server's count of unrecognized startup options but then always read exactly one option-name string regardless of that count - a count of 0 (version mismatch only, no unrecognized options) or 2+ would desync the buffer offset and corrupt every message parsed after it. It now reads exactly `count` strings into NegotiateProtocolVersionMessage.unrecognizedOptions. The message was also parsed but never dispatched anywhere, so a real negotiation reply from the server was silently discarded. PgSocket now stores it and emits a 'debug' event; IntlConnection and Connection each expose it as a new readonly protocolNegotiation getter, mirroring the existing processID/secretKey/sessionParameters delegation chain - undefined means the server recognized everything this client's startup packet asked for. Co-Authored-By: Claude Sonnet 5 --- src/connection/connection.ts | 13 ++++++++++ src/connection/intl-connection.ts | 5 ++++ src/protocol/backend.ts | 11 +++++---- src/protocol/pg-socket.ts | 36 +++++++++++++++++++++++++++ src/protocol/protocol.ts | 9 +++++-- test/A-common/backend.spec.ts | 41 +++++++++++++++++++++++++++---- test/A-common/pg-socket.spec.ts | 14 +++++++++++ 7 files changed, 117 insertions(+), 12 deletions(-) diff --git a/src/connection/connection.ts b/src/connection/connection.ts index 62917aa..351a217 100644 --- a/src/connection/connection.ts +++ b/src/connection/connection.ts @@ -82,6 +82,19 @@ export class Connection extends SafeEventEmitter implements AsyncDisposable { return this._intlCon.sessionParameters; } + /** + * The server's NegotiateProtocolVersion reply, if it sent one during + * connect() - undefined means the server fully recognized everything + * this client's startup packet asked for (protocol minor version, any + * `_pq_.*` options). Present only when the server is older/stricter + * than what was requested, or didn't recognize one of the options - + * check it after connect() if a feature gated behind such an option + * doesn't seem to have taken effect. + */ + get protocolNegotiation(): Maybe { + return this._intlCon.protocolNegotiation; + } + /** * Returns the secret key of the current session */ diff --git a/src/connection/intl-connection.ts b/src/connection/intl-connection.ts index c6243b4..b709b29 100644 --- a/src/connection/intl-connection.ts +++ b/src/connection/intl-connection.ts @@ -95,6 +95,11 @@ export class IntlConnection extends SafeEventEmitter { return this.socket.secretKey; } + /** See `PgSocket.protocolNegotiation`. */ + get protocolNegotiation(): Maybe { + return this.socket.protocolNegotiation; + } + get sessionParameters(): Record { return this.socket.sessionParameters; } diff --git a/src/protocol/backend.ts b/src/protocol/backend.ts index 79d4ea4..698d248 100644 --- a/src/protocol/backend.ts +++ b/src/protocol/backend.ts @@ -290,11 +290,12 @@ function parseFunctionCallResponse( function parseNegotiateProtocolVersion( io: BufferReader, ): Protocol.NegotiateProtocolVersionMessage { - return { - supportedVersionMinor: io.readUInt32BE(), - numberOfNotSupportedVersions: io.readUInt32BE(), - option: io.readCString('utf8'), - } as Protocol.NegotiateProtocolVersionMessage; + const supportedVersionMinor = io.readUInt32BE(); + const count = io.readUInt32BE(); + const unrecognizedOptions: string[] = new Array(count); + let i: number; + for (i = 0; i < count; i++) unrecognizedOptions[i] = io.readCString('utf8'); + return { supportedVersionMinor, unrecognizedOptions }; } function parseParameterDescription( diff --git a/src/protocol/pg-socket.ts b/src/protocol/pg-socket.ts index 8b28c18..e139d54 100644 --- a/src/protocol/pg-socket.ts +++ b/src/protocol/pg-socket.ts @@ -43,6 +43,7 @@ export class PgSocket extends SafeEventEmitter { private _saslSession?: SASL.Session; private _processID?: number; private _secretKey?: number; + private _protocolNegotiation?: Protocol.NegotiateProtocolVersionMessage; private _captureQueue = new DoublyLinked(); private _pendingWrites: { data: Buffer; cb?: Callback }[] = []; private _flushScheduled = false; @@ -71,6 +72,17 @@ export class PgSocket extends SafeEventEmitter { return this._secretKey; } + /** + * The server's NegotiateProtocolVersion reply, if it sent one - + * undefined means everything this client asked for in its startup + * packet (protocol minor version, any `_pq_.*` options) was fully + * recognized. Present only when the server is older/stricter than what + * was requested, or didn't recognize one of the startup options. + */ + get protocolNegotiation(): Maybe { + return this._protocolNegotiation; + } + get sessionParameters(): Record { return this._sessionParameters; } @@ -702,6 +714,11 @@ export class PgSocket extends SafeEventEmitter { payload as Protocol.ParameterStatusMessage, ); break; + case Protocol.BackendMessageCode.NegotiateProtocolVersion: + this._handleNegotiateProtocolVersion( + payload as Protocol.NegotiateProtocolVersionMessage, + ); + break; case Protocol.BackendMessageCode.BackendKeyData: this._handleBackendKeyData( payload as Protocol.BackendKeyDataMessage, @@ -876,6 +893,25 @@ export class PgSocket extends SafeEventEmitter { this._sessionParameters[msg.name] = msg.value; } + protected _handleNegotiateProtocolVersion( + msg: Protocol.NegotiateProtocolVersionMessage, + ): void { + this._protocolNegotiation = msg; + /* c8 ignore start */ + if (this.listenerCount('debug')) { + this.emit('debug', { + location: 'PgSocket._handleNegotiateProtocolVersion', + message: + `server supports protocol 3.${msg.supportedVersionMinor}` + + (msg.unrecognizedOptions.length + ? `; did not recognize startup option(s): ${msg.unrecognizedOptions.join(', ')}` + : ''), + ...msg, + }); + } + /* c8 ignore stop */ + } + protected _handleBackendKeyData(msg: Protocol.BackendKeyDataMessage): void { this._processID = msg.processID; this._secretKey = msg.secretKey; diff --git a/src/protocol/protocol.ts b/src/protocol/protocol.ts index 55e6d23..71ae896 100644 --- a/src/protocol/protocol.ts +++ b/src/protocol/protocol.ts @@ -173,9 +173,14 @@ export namespace Protocol { } export interface NegotiateProtocolVersionMessage { + /** Newest minor protocol version the server supports. */ supportedVersionMinor: number; - numberOfNotSupportedVersions: number; - option: string; + /** + * Startup packet options this client sent that the server didn't + * recognize - empty when the only mismatch is the protocol minor + * version itself. + */ + unrecognizedOptions: string[]; } export interface ParameterDescriptionMessage { diff --git a/test/A-common/backend.spec.ts b/test/A-common/backend.spec.ts index 1e529a7..0fd0ff5 100644 --- a/test/A-common/backend.spec.ts +++ b/test/A-common/backend.spec.ts @@ -123,18 +123,49 @@ describe('Backend (wire message parsing)', () => { }); describe('NegotiateProtocolVersion', () => { - it('should read the minor version, unsupported-option count and name', () => { + it('should read the minor version and one unrecognized option name', () => { const body = Buffer.alloc(12); body.writeUInt32BE(2, 0); // supportedVersionMinor - body.writeUInt32BE(1, 4); // numberOfNotSupportedVersions - body.write('foo\0', 8, 'utf8'); // option + body.writeUInt32BE(1, 4); // count + body.write('foo\0', 8, 'utf8'); const { msg } = parseOne( message(Protocol.BackendMessageCode.NegotiateProtocolVersion, body), ); expect(msg).toStrictEqual({ supportedVersionMinor: 2, - numberOfNotSupportedVersions: 1, - option: 'foo', + unrecognizedOptions: ['foo'], + }); + }); + + it('should read zero unrecognized options without consuming any string', () => { + // A mismatched protocol minor version alone, with every startup + // option otherwise recognized, reports a count of 0 and no strings + // follow - reading one anyway (the bug this guards against) would + // desync every message parsed after this one. + const body = Buffer.alloc(8); + body.writeUInt32BE(0, 0); // supportedVersionMinor + body.writeUInt32BE(0, 4); // count + const { msg } = parseOne( + message(Protocol.BackendMessageCode.NegotiateProtocolVersion, body), + ); + expect(msg).toStrictEqual({ + supportedVersionMinor: 0, + unrecognizedOptions: [], + }); + }); + + it('should read multiple unrecognized option names in order', () => { + const body = Buffer.alloc(8 + 4 + 4); + body.writeUInt32BE(2, 0); // supportedVersionMinor + body.writeUInt32BE(2, 4); // count + body.write('foo\0', 8, 'utf8'); + body.write('bar\0', 12, 'utf8'); + const { msg } = parseOne( + message(Protocol.BackendMessageCode.NegotiateProtocolVersion, body), + ); + expect(msg).toStrictEqual({ + supportedVersionMinor: 2, + unrecognizedOptions: ['foo', 'bar'], }); }); }); diff --git a/test/A-common/pg-socket.spec.ts b/test/A-common/pg-socket.spec.ts index 4d582e3..7e362cf 100644 --- a/test/A-common/pg-socket.spec.ts +++ b/test/A-common/pg-socket.spec.ts @@ -11,6 +11,20 @@ describe('PgSocket', () => { }); }); + describe('protocolNegotiation', () => { + it('should start out undefined before any NegotiateProtocolVersion arrives', () => { + const socket = new PgSocket({}); + expect(socket.protocolNegotiation).toBeUndefined(); + }); + + it('should store the message once _handleNegotiateProtocolVersion runs', () => { + const socket: any = new PgSocket({}); + const msg = { supportedVersionMinor: 0, unrecognizedOptions: ['foo'] }; + socket._handleNegotiateProtocolVersion(msg); + expect(socket.protocolNegotiation).toStrictEqual(msg); + }); + }); + describe('_handleAuthenticationMessage()', () => { it('should throw on an authentication method it does not support', () => { const socket: any = new PgSocket({}); From c39890f31310569a7277315d66935bdbef57c06c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Eray=20Hano=C4=9Flu?= Date: Wed, 9 Sep 2026 20:28:03 +0300 Subject: [PATCH 2/7] feat: implement the legacy Function Call sub-protocol ('F'/'V') Adds Connection.callFunction(functionId, args, options?), calling a function by OID directly instead of through SQL - superseded in current PostgreSQL by SELECT func(...) over Simple/Extended Query, which is why no other current client bothers with it, but it was one of the last gaps in this driver's own wire-protocol coverage. Arguments and the result are raw wire-format bytes rather than typed JS values, matching the low-level nature of the protocol itself. Also fixes a real parsing bug found while wiring this up: parseFunctionCallResponse read `len - 4` bytes as the result directly, but the result value has its own length prefix ahead of it (-1 for SQL NULL, no bytes following in that case) - the old code folded that prefix into the returned buffer as leading garbage and never supported NULL at all. Co-Authored-By: Claude Sonnet 5 --- src/connection/connection.ts | 32 ++++++++++++ src/connection/intl-connection.ts | 56 ++++++++++++++++++++ src/index.ts | 2 + src/interfaces/function-call-options.ts | 15 ++++++ src/interfaces/function-call-result.ts | 7 +++ src/protocol/backend.ts | 10 ++-- src/protocol/frontend.ts | 48 +++++++++++++++++ src/protocol/pg-socket.ts | 13 +++++ src/protocol/protocol.ts | 3 +- test/A-common/backend.spec.ts | 20 +++++-- test/A-common/frontend.spec.ts | 35 +++++++++++++ test/B-connection/16-function-call.spec.ts | 61 ++++++++++++++++++++++ 12 files changed, 292 insertions(+), 10 deletions(-) create mode 100644 src/interfaces/function-call-options.ts create mode 100644 src/interfaces/function-call-result.ts create mode 100644 test/B-connection/16-function-call.spec.ts diff --git a/src/connection/connection.ts b/src/connection/connection.ts index 351a217..c163074 100644 --- a/src/connection/connection.ts +++ b/src/connection/connection.ts @@ -1,6 +1,8 @@ import { ConnectionState, DataTypeOIDs } from '../constants.js'; import { GlobalTypeMap } from '../data-type-map.js'; import type { ConnectionConfiguration } from '../interfaces/database-connection-params.js'; +import type { FunctionCallOptions } from '../interfaces/function-call-options.js'; +import type { FunctionCallResult } from '../interfaces/function-call-result.js'; import type { QueryOptions } from '../interfaces/query-options.js'; import type { QueryResult } from '../interfaces/query-result.js'; import type { ScriptExecuteOptions } from '../interfaces/script-execute-options.js'; @@ -206,6 +208,36 @@ export class Connection extends SafeEventEmitter implements AsyncDisposable { ); } + /** + * The legacy Function Call sub-protocol - calls a function by OID + * directly, bypassing SQL entirely. Superseded by `SELECT func(...)` + * over the Simple/Extended Query protocols (what `execute()`/`query()` + * use, and what every current PostgreSQL client uses exclusively) - + * kept only for wire-protocol completeness. Arguments and the result + * travel as raw wire-format bytes, not JS values: the caller is + * responsible for encoding/decoding them (see a `DataType`'s own + * `encodeBinary`/`decodeBinary` for the format a given OID expects). + * + * @param functionId - OID of the function to call + * @param args - Each argument's own already-encoded wire bytes, or + * `null` for SQL NULL + */ + callFunction( + functionId: OID, + args: Maybe[], + options?: FunctionCallOptions & { signal?: AbortSignal }, + ): Promise { + return withAbortSignal( + options?.signal, + () => this._intlCon.cancel(), + () => + this._captureErrorStack( + this._intlCon.callFunction(functionId, args, options), + this.callFunction, + ), + ); + } + async query( sql: string | QueryRequest, options?: QueryOptions, diff --git a/src/connection/intl-connection.ts b/src/connection/intl-connection.ts index b709b29..bfdd25b 100644 --- a/src/connection/intl-connection.ts +++ b/src/connection/intl-connection.ts @@ -5,6 +5,8 @@ import { GlobalTypeMap } from '../data-type-map.js'; import type { CommandResult } from '../interfaces/command-result.js'; import type { ConnectionConfiguration } from '../interfaces/database-connection-params.js'; import type { FieldInfo } from '../interfaces/field-info.js'; +import type { FunctionCallOptions } from '../interfaces/function-call-options.js'; +import type { FunctionCallResult } from '../interfaces/function-call-result.js'; import type { QueryOptions } from '../interfaces/query-options.js'; import type { QueryResult } from '../interfaces/query-result.js'; import type { ScriptExecuteOptions } from '../interfaces/script-execute-options.js'; @@ -200,6 +202,60 @@ export class IntlConnection extends SafeEventEmitter { } } + /** + * The legacy Function Call sub-protocol: calls a function by OID + * directly, bypassing SQL entirely. PostgreSQL itself calls this + * superseded by `SELECT func(...)` over the Simple/Extended Query + * protocols (what this driver uses everywhere else, and what every + * other current client uses exclusively) - kept only for wire-protocol + * completeness. Arguments and the result travel as raw wire-format + * bytes rather than through this driver's usual typed encode/decode + * pipeline - the caller is responsible for both (see a `DataType`'s own + * `encodeBinary`/`decodeBinary` for the format a given OID expects). + */ + async callFunction( + functionId: OID, + args: Maybe[], + options: FunctionCallOptions = {}, + ): Promise { + this.assertConnected(); + this.ref(); + try { + let result: FunctionCallResult | undefined; + let error: Error | undefined; + return await this.socket.sendFunctionCallMessage( + { + functionId, + args, + argFormats: options.argFormats, + resultFormat: options.resultFormat, + }, + (code, msg, done) => { + switch (code) { + case Protocol.BackendMessageCode.ErrorResponse: + error = msg; + break; + case Protocol.BackendMessageCode.FunctionCallResponse: + result = msg as FunctionCallResult; + break; + case Protocol.BackendMessageCode.ReadyForQuery: + this.transactionStatus = msg.status; + if (error) { + done(error); + break; + } + done(undefined, result); + break; + default: + break; + } + }, + ); + } finally { + this.unref(); + } + } + /** * Starts a transaction, or - if one is already running - marks a nested * level of it. Each call increments `_transactionDepth`; only the diff --git a/src/index.ts b/src/index.ts index 38eedd5..85d6b36 100644 --- a/src/index.ts +++ b/src/index.ts @@ -13,6 +13,8 @@ export * from './interfaces/data-mapping-options.js'; export * from './interfaces/data-type.js'; export * from './interfaces/database-connection-params.js'; export * from './interfaces/field-info.js'; +export * from './interfaces/function-call-options.js'; +export * from './interfaces/function-call-result.js'; export * from './interfaces/query-options.js'; export * from './interfaces/query-result.js'; export * from './interfaces/script-execute-options.js'; diff --git a/src/interfaces/function-call-options.ts b/src/interfaces/function-call-options.ts new file mode 100644 index 0000000..ec2f5d1 --- /dev/null +++ b/src/interfaces/function-call-options.ts @@ -0,0 +1,15 @@ +import type { DataFormat } from '../constants.js'; + +export interface FunctionCallOptions { + /** + * Format of each argument, applied positionally - omit for text (the + * default) applied to every argument, a single entry to apply it to all + * of them, or one entry per argument. + */ + argFormats?: DataFormat[]; + /** + * Format the function's return value comes back in. + * @default DataFormat.text + */ + resultFormat?: DataFormat; +} diff --git a/src/interfaces/function-call-result.ts b/src/interfaces/function-call-result.ts new file mode 100644 index 0000000..c2a8865 --- /dev/null +++ b/src/interfaces/function-call-result.ts @@ -0,0 +1,7 @@ +export interface FunctionCallResult { + /** + * The function's return value, in the wire format requested via + * `FunctionCallOptions.resultFormat` - `null` if it returned SQL NULL. + */ + result: Buffer | null; +} diff --git a/src/protocol/backend.ts b/src/protocol/backend.ts index 698d248..0515abc 100644 --- a/src/protocol/backend.ts +++ b/src/protocol/backend.ts @@ -279,12 +279,12 @@ function parseNotificationResponse( function parseFunctionCallResponse( io: BufferReader, - code: Protocol.BackendMessageCode, - len: number, ): Protocol.FunctionCallResponseMessage { - return { - result: io.readBuffer(len - 4), - } as Protocol.FunctionCallResponseMessage; + // A length prefix ahead of the value itself (-1 for SQL NULL, with no + // bytes following) - not deducible from the outer message length alone, + // unlike most fixed-shape messages this codebase parses. + const len = io.readInt32BE(); + return { result: len < 0 ? null : io.readBuffer(len) }; } function parseNegotiateProtocolVersion( diff --git a/src/protocol/frontend.ts b/src/protocol/frontend.ts index 9b0f050..847a1a4 100644 --- a/src/protocol/frontend.ts +++ b/src/protocol/frontend.ts @@ -79,6 +79,30 @@ export namespace Frontend { type: 'P' | 'S'; name?: string; } + + /** + * The legacy Function Call sub-protocol - calls a function by OID + * directly, bypassing SQL entirely. Superseded by `SELECT func(...)` + * over the Simple/Extended Query protocols (which every current + * PostgreSQL client, this one included for everything else, uses + * exclusively) - kept only for wire-protocol completeness. Arguments + * and the result travel as raw wire-format bytes, not JS values: the + * caller is responsible for encoding/decoding them (see a `DataType`'s + * own `encodeBinary`/`decodeBinary` for the format a given OID expects). + */ + export interface FunctionCallMessageArgs { + functionId: OID; + /** + * Format of each argument in `args`, applied positionally - empty + * means text for all of them, a single entry applies to all, or it + * must have exactly one entry per argument. + */ + argFormats?: Protocol.DataFormat[]; + /** `null` for an SQL NULL argument. */ + args: Maybe[]; + /** Format the function's return value comes back in. @default text */ + resultFormat?: Protocol.DataFormat; + } } export class Frontend { @@ -356,6 +380,30 @@ export class Frontend { return setLengthAndFlush(io, 1); } + getFunctionCallMessage(args: Frontend.FunctionCallMessageArgs): Buffer { + const { functionId, args: values, argFormats, resultFormat } = args; + const formats = argFormats || []; + const fl = formats.length; + const l = values.length; + let i: number; + const io = this._io + .start() + .writeInt8(Protocol.FrontendMessageCode.FunctionCall) + .writeInt32BE(0) // Preserve header + .writeInt32BE(functionId) + .writeInt16BE(fl); + for (i = 0; i < fl; i++) io.writeInt16BE(formats[i]); + io.writeInt16BE(l); + let v: Maybe; + for (i = 0; i < l; i++) { + v = values[i]; + if (v == null) io.writeInt32BE(-1); + else io.writeInt32BE(v.length).writeBuffer(v); + } + io.writeInt16BE(resultFormat ?? DataFormat.text); + return setLengthAndFlush(io, 1); + } + /** * Returns the CopyData header and the caller's payload as two buffers * rather than one, so a bulk import never copies the caller's bytes. diff --git a/src/protocol/pg-socket.ts b/src/protocol/pg-socket.ts index e139d54..7622949 100644 --- a/src/protocol/pg-socket.ts +++ b/src/protocol/pg-socket.ts @@ -289,6 +289,19 @@ export class PgSocket extends SafeEventEmitter { ); } + /** The legacy Function Call sub-protocol - see `Frontend.FunctionCallMessageArgs`. */ + sendFunctionCallMessage( + args: Frontend.FunctionCallMessageArgs, + cb: CaptureCallback, + ): Promise { + return this._sendAndCapture( + this._frontend.getFunctionCallMessage(args), + cb, + 'sendFunctionCallMessage', + args, + ); + } + /** * Sends Parse+Bind+Describe+Execute+Sync as a single write with a single * FIFO capture entry, instead of 5 separate sendXMessage() round trips - diff --git a/src/protocol/protocol.ts b/src/protocol/protocol.ts index 71ae896..5ec9d6c 100644 --- a/src/protocol/protocol.ts +++ b/src/protocol/protocol.ts @@ -169,7 +169,8 @@ export namespace Protocol { } export interface FunctionCallResponseMessage { - result: Buffer; + /** `null` when the function returned SQL NULL. */ + result: Buffer | null; } export interface NegotiateProtocolVersionMessage { diff --git a/test/A-common/backend.spec.ts b/test/A-common/backend.spec.ts index 0fd0ff5..49d3c60 100644 --- a/test/A-common/backend.spec.ts +++ b/test/A-common/backend.spec.ts @@ -110,15 +110,27 @@ describe('Backend (wire message parsing)', () => { }); describe('FunctionCallResponse', () => { - it('should read the result as a raw buffer', () => { - const result = Buffer.from([0xde, 0xad, 0xbe, 0xef]); + it('should read the result value behind its own length prefix', () => { + const value = Buffer.from([0xde, 0xad, 0xbe, 0xef]); + const body = Buffer.alloc(4 + value.length); + body.writeInt32BE(value.length, 0); + value.copy(body, 4); const { code, msg } = parseOne( - message(Protocol.BackendMessageCode.FunctionCallResponse, result), + message(Protocol.BackendMessageCode.FunctionCallResponse, body), ); expect(code).toStrictEqual( Protocol.BackendMessageCode.FunctionCallResponse, ); - expect(msg.result).toStrictEqual(result); + expect(msg.result).toStrictEqual(value); + }); + + it('should read a -1 length prefix as a null result, with no bytes following', () => { + const body = Buffer.alloc(4); + body.writeInt32BE(-1, 0); + const { msg } = parseOne( + message(Protocol.BackendMessageCode.FunctionCallResponse, body), + ); + expect(msg.result).toBeNull(); }); }); diff --git a/test/A-common/frontend.spec.ts b/test/A-common/frontend.spec.ts index 9095ba9..8f88ab2 100644 --- a/test/A-common/frontend.spec.ts +++ b/test/A-common/frontend.spec.ts @@ -348,6 +348,41 @@ describe('Frontend', () => { }); }); + describe('getFunctionCallMessage()', () => { + it('should write function id, arg formats, each arg (or -1 for null), and the result format', () => { + const frontend = new Frontend({}); + const arg0 = Buffer.from([0x01, 0x02]); + const buf = frontend.getFunctionCallMessage({ + functionId: 1234, + argFormats: [DataFormat.binary], + args: [arg0, null], + resultFormat: DataFormat.binary, + }); + const io = reader(buf); + expect(io.readInt32BE()).toStrictEqual(1234); + expect(io.readInt16BE()).toStrictEqual(1); // argFormats.length + expect(io.readInt16BE()).toStrictEqual(DataFormat.binary); + expect(io.readInt16BE()).toStrictEqual(2); // args.length + expect(io.readInt32BE()).toStrictEqual(arg0.length); + expect(io.readBuffer(arg0.length)).toStrictEqual(arg0); + expect(io.readInt32BE()).toStrictEqual(-1); // null arg + expect(io.readInt16BE()).toStrictEqual(DataFormat.binary); // result format + }); + + it('should default to an empty argFormats and text result format', () => { + const frontend = new Frontend({}); + const buf = frontend.getFunctionCallMessage({ + functionId: 1, + args: [], + }); + const io = reader(buf); + expect(io.readInt32BE()).toStrictEqual(1); + expect(io.readInt16BE()).toStrictEqual(0); // argFormats.length + expect(io.readInt16BE()).toStrictEqual(0); // args.length + expect(io.readInt16BE()).toStrictEqual(DataFormat.text); + }); + }); + describe('getCopyFailMessage()', () => { it('should default a falsy message to the empty string', () => { const frontend = new Frontend({}); diff --git a/test/B-connection/16-function-call.spec.ts b/test/B-connection/16-function-call.spec.ts new file mode 100644 index 0000000..69a04d7 --- /dev/null +++ b/test/B-connection/16-function-call.spec.ts @@ -0,0 +1,61 @@ +import { expect } from 'expect'; +import { Connection, DataFormat } from 'postgrejs'; + +describe('callFunction() (legacy Function Call sub-protocol)', () => { + let connection: Connection; + + before(async () => { + connection = new Connection(); + await connection.connect(); + }); + + after(async () => { + await connection.close(); + }); + + async function findOid(proname: string, pronargs: number): Promise { + const result = await connection.query( + 'select oid from pg_proc where proname = $1 and pronargs = $2', + { params: [proname, pronargs] }, + ); + return Number((result.rows as any[])[0][0]); + } + + it('should call a built-in function by OID with text arguments/result', async () => { + // md5, not upper: the latter needs a collation, which the executor + // can only resolve from a parsed SQL expression - a raw FunctionCall + // argument has no such context, so it fails with "could not + // determine which collation to use". md5 doesn't care about + // collation at all, so it works the same through either protocol. + const oid = await findOid('md5', 1); + const result = await connection.callFunction(oid, [ + Buffer.from('hello', 'utf8'), + ]); + expect(result.result?.toString('utf8')).toStrictEqual( + '5d41402abc4b2a76b9719d911017c592', + ); + }); + + it('should call a built-in function with binary arguments/result', async () => { + const oid = await findOid('int4pl', 2); + const a = Buffer.alloc(4); + a.writeInt32BE(2); + const b = Buffer.alloc(4); + b.writeInt32BE(3); + const result = await connection.callFunction(oid, [a, b], { + argFormats: [DataFormat.binary], + resultFormat: DataFormat.binary, + }); + expect(result.result?.readInt32BE()).toStrictEqual(5); + }); + + it('should return a null result for a strict function called with a null argument', async () => { + const oid = await findOid('md5', 1); + const result = await connection.callFunction(oid, [null]); + expect(result.result).toBeNull(); + }); + + it('should reject with the server error for an unknown function OID', async () => { + await expect(connection.callFunction(0, [])).rejects.toThrow(); + }); +}); From 421aa5c72c1b1db2a505880e709fa15254ff6a18 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Eray=20Hano=C4=9Flu?= Date: Wed, 9 Sep 2026 20:51:44 +0300 Subject: [PATCH 3/7] feat: support protocol 3.2's longer cancellation key (PostgreSQL 18+) Adds DatabaseConnectionParams.longCancelKey (default false): requests protocol 3.2 instead of 3.0, which lets the server hand out a cancellation key up to 256 bytes instead of always exactly 4 - the short key is brute-forceable by an attacker who wants to cancel another session's query. Off by default, and never a connection-breaking choice when on: an older server just replies with NegotiateProtocolVersion naming the highest minor version it supports, and the session proceeds there exactly as if longCancelKey had never been set. BackendKeyData's secretKey is now a Buffer instead of a fixed-width number - it has no length prefix of its own, so parseBackendKeyData reads whatever bytes remain in the message (4 of them before 3.2, up to 256 with it). getCancelRequestMessage relays that Buffer back unchanged rather than assuming 4 bytes. This is a breaking change to the public secretKey getter on PgSocket/IntlConnection/Connection. Verified against the real PostgreSQL 18 test server: longCancelKey:true hands back a key longer than 4 bytes with no protocol negotiation needed, and cancel() still genuinely interrupts a running query with it. Co-Authored-By: Claude Sonnet 5 --- src/connection/connection.ts | 2 +- src/connection/intl-connection.ts | 2 +- src/interfaces/database-connection-params.ts | 12 ++++++ src/protocol/backend.ts | 11 +++-- src/protocol/frontend.ts | 13 +++--- src/protocol/pg-socket.ts | 25 ++++++----- src/protocol/protocol.ts | 19 ++++++++- test/A-common/backend.spec.ts | 27 ++++++++++++ test/A-common/frontend.spec.ts | 39 +++++++++++++++++ test/B-connection/01-connection.spec.ts | 5 ++- test/B-connection/17-long-cancel-key.spec.ts | 45 ++++++++++++++++++++ 11 files changed, 177 insertions(+), 23 deletions(-) create mode 100644 test/B-connection/17-long-cancel-key.spec.ts diff --git a/src/connection/connection.ts b/src/connection/connection.ts index c163074..54f6b18 100644 --- a/src/connection/connection.ts +++ b/src/connection/connection.ts @@ -100,7 +100,7 @@ export class Connection extends SafeEventEmitter implements AsyncDisposable { /** * Returns the secret key of the current session */ - get secretKey(): Maybe { + get secretKey(): Maybe { return this._intlCon.secretKey; } diff --git a/src/connection/intl-connection.ts b/src/connection/intl-connection.ts index bfdd25b..1770684 100644 --- a/src/connection/intl-connection.ts +++ b/src/connection/intl-connection.ts @@ -93,7 +93,7 @@ export class IntlConnection extends SafeEventEmitter { return this.socket.processID; } - get secretKey(): Maybe { + get secretKey(): Maybe { return this.socket.secretKey; } diff --git a/src/interfaces/database-connection-params.ts b/src/interfaces/database-connection-params.ts index 7e6a8bd..d292bee 100644 --- a/src/interfaces/database-connection-params.ts +++ b/src/interfaces/database-connection-params.ts @@ -59,6 +59,18 @@ export interface DatabaseConnectionParams { * usual case with `sslmode=require` or a self-signed certificate. */ channelBinding?: 'prefer' | 'require' | 'disable'; + /** + * Requests protocol 3.2 (PostgreSQL 18+) instead of 3.0, so a + * `cancel()` in progress on this connection can't be forged by an + * attacker guessing a 4-byte secret key - 3.2 lets the server hand out + * one up to 256 bytes instead. Off by default: an older server simply + * reports back that it doesn't support 3.2 (see + * `Connection.protocolNegotiation`) and the session proceeds at 3.0 + * exactly as if this had never been set, so turning it on is never a + * connection-breaking choice - just not something every server rewards + * yet. + */ + longCancelKey?: boolean; requireSSL?: boolean; ssl?: TlsConnectionOptions; timezone?: string; diff --git a/src/protocol/backend.ts b/src/protocol/backend.ts index 0515abc..b4460d5 100644 --- a/src/protocol/backend.ts +++ b/src/protocol/backend.ts @@ -197,10 +197,13 @@ function parseAuthentication( } function parseBackendKeyData(io: BufferReader): Protocol.BackendKeyDataMessage { - return { - processID: io.readUInt32BE(), - secretKey: io.readUInt32BE(), - } as Protocol.BackendKeyDataMessage; + const processID = io.readUInt32BE(); + // No length prefix of its own: this BufferReader is scoped to exactly + // this message's body (see Backend.parse()), so the secret key is + // simply whatever bytes remain - 4 of them before protocol 3.2, up to + // 256 with it (see VERSION_MINOR_LONG_CANCEL_KEY). + const secretKey = io.readBuffer(); + return { processID, secretKey }; } function parseCommandComplete( diff --git a/src/protocol/frontend.ts b/src/protocol/frontend.ts index 847a1a4..d54485e 100644 --- a/src/protocol/frontend.ts +++ b/src/protocol/frontend.ts @@ -121,12 +121,15 @@ export class Frontend { .flush(); } - getStartupMessage(args: Frontend.StartupMessageArgs): Buffer { + getStartupMessage( + args: Frontend.StartupMessageArgs, + minorVersion: number = Protocol.VERSION_MINOR, + ): Buffer { const io = this._io .start() .writeInt32BE(0) // Preserve length .writeInt16BE(Protocol.VERSION_MAJOR) - .writeInt16BE(Protocol.VERSION_MINOR); + .writeInt16BE(minorVersion); const entries = Object.entries(args); const l = entries.length; let k: string; @@ -148,14 +151,14 @@ export class Frontend { * is not reading its own socket, which is the whole reason this cannot go * down the normal one. */ - getCancelRequestMessage(processID: number, secretKey: number): Buffer { + getCancelRequestMessage(processID: number, secretKey: Buffer): Buffer { return this._io .start() - .writeUInt32BE(16) // Length of message contents in bytes, including self. + .writeUInt32BE(12 + secretKey.length) // Length of message contents in bytes, including self. .writeUInt16BE(1234) .writeUInt16BE(5678) .writeUInt32BE(processID) - .writeUInt32BE(secretKey) + .writeBuffer(secretKey) .flush(); } diff --git a/src/protocol/pg-socket.ts b/src/protocol/pg-socket.ts index 7622949..ff79007 100644 --- a/src/protocol/pg-socket.ts +++ b/src/protocol/pg-socket.ts @@ -42,7 +42,7 @@ export class PgSocket extends SafeEventEmitter { private _sessionParameters: Record = {}; private _saslSession?: SASL.Session; private _processID?: number; - private _secretKey?: number; + private _secretKey?: Buffer; private _protocolNegotiation?: Protocol.NegotiateProtocolVersionMessage; private _captureQueue = new DoublyLinked(); private _pendingWrites: { data: Buffer; cb?: Callback }[] = []; @@ -68,7 +68,7 @@ export class PgSocket extends SafeEventEmitter { return this._processID; } - get secretKey(): Maybe { + get secretKey(): Maybe { return this._secretKey; } @@ -580,14 +580,19 @@ export class PgSocket extends SafeEventEmitter { socket.on('error', (err: SocketError) => this._handleError(err)); socket.on('close', () => this._handleClose()); this._send( - this._frontend.getStartupMessage({ - user: this.options.user || 'postgres', - database: this.options.database || '', - application_name: this.options.applicationName || '', - ...(this.options.replication - ? { replication: this.options.replication } - : undefined), - }), + this._frontend.getStartupMessage( + { + user: this.options.user || 'postgres', + database: this.options.database || '', + application_name: this.options.applicationName || '', + ...(this.options.replication + ? { replication: this.options.replication } + : undefined), + }, + this.options.longCancelKey + ? Protocol.VERSION_MINOR_LONG_CANCEL_KEY + : undefined, + ), ); } diff --git a/src/protocol/protocol.ts b/src/protocol/protocol.ts index 5ec9d6c..160e504 100644 --- a/src/protocol/protocol.ts +++ b/src/protocol/protocol.ts @@ -1,6 +1,16 @@ export namespace Protocol { export const VERSION_MAJOR = 3; export const VERSION_MINOR = 0; + /** + * Minor version 3.2 (PostgreSQL 18+) differs from 3.0 only in that + * BackendKeyData's secret key - and so CancelRequest's - can be up to + * 256 bytes instead of always exactly 4. A server that doesn't support + * 3.2 replies with NegotiateProtocolVersion naming the highest minor + * version it does support (see `PgSocket.protocolNegotiation`) and the + * session simply proceeds at that version instead - requesting 3.2 is + * never a connection-breaking choice, only ever a possible no-op. + */ + export const VERSION_MINOR_LONG_CANCEL_KEY = 2; // https://www.postgresql.org/docs/9.3/protocol-message-formats.html export enum BackendMessageCode { @@ -116,7 +126,14 @@ export namespace Protocol { export interface BackendKeyDataMessage { processID: number; - secretKey: number; + /** + * Always 4 bytes before protocol 3.2, up to 256 with it - see + * `VERSION_MINOR_LONG_CANCEL_KEY`. Not a fixed-width int, unlike + * before: the field carries whatever bytes the server sent, in + * whatever order it sent them, to be relayed back to CancelRequest + * unchanged rather than interpreted as a number. + */ + secretKey: Buffer; } export interface CommandCompleteMessage { diff --git a/test/A-common/backend.spec.ts b/test/A-common/backend.spec.ts index 49d3c60..2c7b203 100644 --- a/test/A-common/backend.spec.ts +++ b/test/A-common/backend.spec.ts @@ -109,6 +109,33 @@ describe('Backend (wire message parsing)', () => { }); }); + describe('BackendKeyData', () => { + it('should read a legacy 4-byte secret key (protocol 3.0)', () => { + const body = Buffer.alloc(8); + body.writeUInt32BE(1234, 0); // processID + body.write('\xd5\xd4\xb4\x4f', 4, 'binary'); // secretKey, 4 bytes + const { msg } = parseOne( + message(Protocol.BackendMessageCode.BackendKeyData, body), + ); + expect(msg.processID).toStrictEqual(1234); + expect(Buffer.isBuffer(msg.secretKey)).toBe(true); + expect(msg.secretKey).toStrictEqual( + Buffer.from([0xd5, 0xd4, 0xb4, 0x4f]), + ); + }); + + it('should read a longer secret key (protocol 3.2) as whatever bytes remain', () => { + const key = Buffer.from(Array.from({ length: 32 }, (_, i) => i)); + const body = Buffer.alloc(4 + key.length); + body.writeUInt32BE(1, 0); + key.copy(body, 4); + const { msg } = parseOne( + message(Protocol.BackendMessageCode.BackendKeyData, body), + ); + expect(msg.secretKey).toStrictEqual(key); + }); + }); + describe('FunctionCallResponse', () => { it('should read the result value behind its own length prefix', () => { const value = Buffer.from([0xde, 0xad, 0xbe, 0xef]); diff --git a/test/A-common/frontend.spec.ts b/test/A-common/frontend.spec.ts index 8f88ab2..8134e5d 100644 --- a/test/A-common/frontend.spec.ts +++ b/test/A-common/frontend.spec.ts @@ -391,4 +391,43 @@ describe('Frontend', () => { expect(io.readCString()).toStrictEqual(''); }); }); + + describe('getStartupMessage()', () => { + // No leading message code byte at all, unlike every other message + // here - StartupMessage/CancelRequest/SSLRequest are identified by + // their own magic number instead, so there is no 5-byte header for + // reader() to skip. + it('should default to protocol 3.0 when no minor version is given', () => { + const frontend = new Frontend({}); + const buf = frontend.getStartupMessage({ user: 'u', database: 'd' }); + expect(buf.readInt16BE(4)).toStrictEqual(3); // major + expect(buf.readInt16BE(6)).toStrictEqual(0); // minor + }); + + it('should write the given minor version', () => { + const frontend = new Frontend({}); + const buf = frontend.getStartupMessage({ user: 'u', database: 'd' }, 2); + expect(buf.readInt16BE(6)).toStrictEqual(2); + }); + }); + + describe('getCancelRequestMessage()', () => { + it('should write a 4-byte legacy secret key unchanged', () => { + const frontend = new Frontend({}); + const secretKey = Buffer.from([0x01, 0x02, 0x03, 0x04]); + const buf = frontend.getCancelRequestMessage(99, secretKey); + expect(buf.readUInt32BE(0)).toStrictEqual(16); // length, incl. self + expect(buf.readUInt32BE(4)).toStrictEqual(80877102); // cancel code + expect(buf.readUInt32BE(8)).toStrictEqual(99); // processID + expect(buf.subarray(12)).toStrictEqual(secretKey); + }); + + it('should write a longer secret key with a correspondingly longer length', () => { + const frontend = new Frontend({}); + const secretKey = Buffer.alloc(32, 0xab); + const buf = frontend.getCancelRequestMessage(1, secretKey); + expect(buf.readUInt32BE(0)).toStrictEqual(12 + secretKey.length); + expect(buf.subarray(12)).toStrictEqual(secretKey); + }); + }); }); diff --git a/test/B-connection/01-connection.spec.ts b/test/B-connection/01-connection.spec.ts index 61ad6a4..525ea7f 100644 --- a/test/B-connection/01-connection.spec.ts +++ b/test/B-connection/01-connection.spec.ts @@ -70,7 +70,10 @@ describe('Connection', () => { it('should get secret key', async () => { connection = new Connection(); await connection.connect(); - expect(connection.secretKey).toBeGreaterThan(0); + // 4 bytes by default (protocol 3.0) - see 17-long-cancel-key.spec.ts + // for the longer key protocol 3.2 (longCancelKey: true) hands back. + expect(Buffer.isBuffer(connection.secretKey)).toBe(true); + expect(connection.secretKey?.length).toStrictEqual(4); }); it('should set application_name', async () => { diff --git a/test/B-connection/17-long-cancel-key.spec.ts b/test/B-connection/17-long-cancel-key.spec.ts new file mode 100644 index 0000000..64730d8 --- /dev/null +++ b/test/B-connection/17-long-cancel-key.spec.ts @@ -0,0 +1,45 @@ +import { expect } from 'expect'; +import { Connection } from 'postgrejs'; + +describe('longCancelKey (protocol 3.2)', () => { + let connection: Connection; + + afterEach(async () => { + if (connection) await connection.close(0); + }); + + it('should hand back a longer secret key than the 4-byte default', async () => { + connection = new Connection({ longCancelKey: true }); + await connection.connect(); + // PostgreSQL sends up to 32 bytes today; the wire format itself + // allows up to 256 - either way, strictly more than the legacy 4. + expect(connection.secretKey?.length).toBeGreaterThan(4); + // Our own test server (PostgreSQL 18) fully supports 3.2, so nothing + // here should have needed negotiating down. + expect(connection.protocolNegotiation).toBeUndefined(); + }); + + it('should still cancel a running query with the longer key', async () => { + connection = new Connection({ longCancelKey: true }); + await connection.connect(); + const ac = new AbortController(); + const started = Date.now(); + setTimeout(() => ac.abort(), 200); + let error: any; + try { + await connection.query('select pg_sleep(10)', { signal: ac.signal }); + } catch (e) { + error = e; + } + expect(error).toBeDefined(); + expect(error.name).toStrictEqual('AbortError'); + expect(Date.now() - started).toBeLessThan(5000); + expect(error.cause?.code).toStrictEqual('57014'); + }); + + it('should default to the legacy 4-byte key when not set', async () => { + connection = new Connection(); + await connection.connect(); + expect(connection.secretKey?.length).toStrictEqual(4); + }); +}); From afc83167e176bb496f73b759ef96438e6c16c591 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Eray=20Hano=C4=9Flu?= Date: Wed, 9 Sep 2026 20:54:44 +0300 Subject: [PATCH 4/7] 3.1.0 --- CHANGELOG.md | 13 ++++++++++++- package-lock.json | 4 ++-- package.json | 2 +- 3 files changed, 15 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 43e95ec..f8b6490 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,17 @@ ## Changelog -### [v3.0.4](https://github.com/panates/postgrejs/compare/v3.0.3...v3.0.4) - +### [v3.1.0](https://github.com/panates/postgrejs/compare/v3.0.4...v3.1.0) - + +#### πŸš€ New Features + +- feat: implement the legacy Function Call sub-protocol ('F'/'V') @Eray Hanoğlu +- feat: support protocol 3.2's longer cancellation key (PostgreSQL 18+) @Eray Hanoğlu + +#### πŸͺ² Fixes + +- fix: correctly parse NegotiateProtocolVersion and expose it on Connection @Eray Hanoğlu + +### [v3.0.4](https://github.com/panates/postgrejs/compare/v3.0.3...v3.0.4) - 9 September 2026 ### [v3.0.3](https://github.com/panates/postgrejs/compare/v3.0.2...v3.0.3) - 8 September 2026 diff --git a/package-lock.json b/package-lock.json index b93006d..4800415 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "postgrejs", - "version": "3.0.4", + "version": "3.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "postgrejs", - "version": "3.0.4", + "version": "3.1.0", "license": "BSD-3-Clause", "dependencies": { "@jsopen/objects": "^2.2.3", diff --git a/package.json b/package.json index e542452..6fc839e 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "postgrejs", "description": "Professional PostgreSQL client NodeJS", - "version": "3.0.4", + "version": "3.1.0", "author": "Panates", "license": "BSD-3-Clause", "private": true, From c857d14b95008b8083e9b86a925920b9162e493b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Eray=20Hano=C4=9Flu?= Date: Wed, 9 Sep 2026 21:05:24 +0300 Subject: [PATCH 5/7] docs: update README for protocol 3.2 support and feature comparison Adds details about new features like long cancellation keys, legacy function call protocol, and graceful protocol renegotiation. Updates version numbers in the feature table for PostgreJS 3.1.0. --- README.md | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 0912526..eb4ae4c 100644 --- a/README.md +++ b/README.md @@ -100,12 +100,18 @@ usage. - **Resource Management:** Auto disposal of resources with the "using" syntax ([TC39 Explicit Resource Management](https://github.com/tc39/proposal-explicit-resource-management)), ensuring efficient resource cleanup. +- **Long Cancel Key:** Opt in to protocol 3.2 (PostgreSQL 18+) with `longCancelKey`, so a `cancel()` in progress can't + be forged by an attacker guessing a short secret key. +- **Legacy Function Call Protocol:** `callFunction()` calls a function by OID directly over the wire, bypassing SQL + entirely - kept for protocol completeness even though `SELECT func(...)` covers the same ground. +- **Graceful Protocol Renegotiation:** A server that doesn't recognize a requested protocol version or startup option + reports back instead of erroring out, surfaced on `Connection.protocolNegotiation`. ## Feature Comparison How PostgreJS compares to [`pg`](https://github.com/brianc/node-postgres) (node-postgres) and [`postgres`](https://github.com/porsager/postgres) (postgres.js). Every row was checked against the libraries' own -source rather than their documentation β€” versions compared: **PostgreJS 2.23.1, pg 8.23.0, postgres.js 3.4.9**. βœ… built +source rather than their documentation β€” versions compared: **PostgreJS 3.1.0, pg 8.23.0, postgres.js 3.4.9**. βœ… built in Β· 🟑 partial or needs a separate package Β· ❌ not supported. | Feature | PostgreJS | pg | postgres.js | @@ -115,11 +121,15 @@ in Β· 🟑 partial or needs a separate package Β· ❌ not supported. | Module system | ESM | ESM/CJS | ESM/CJS | | Language | TS | JS 2 | JS 3 | | ***Wire protocol*** | | | | +| Protocol version | 3.2 | 3.0 | 3.0 | | Simple Query protocol | βœ… | βœ… | βœ… | | Extended Query protocol | βœ… | βœ… | βœ… | | Text wire format | βœ… | βœ… | βœ… | | Binary wire format | βœ… | 🟑 4 | ❌ 5 | | Per-column format selection | βœ… | ❌ | ❌ | +| Long cancel key (opt-in) | βœ… | ❌ | ❌ | +| Legacy Function Call protocol | βœ… | ❌ | ❌ | +| Graceful protocol renegotiation | βœ… | ❌ | ❌ | | ***High-level API*** | | | | | Object and array row modes | βœ… | βœ… | βœ… | | Dynamic SQL helpers | βœ… `sql` tag | ❌ | βœ… | From 151779f74a98868f014302370e142c26d56bfc27 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Eray=20Hano=C4=9Flu?= Date: Wed, 9 Sep 2026 21:09:41 +0300 Subject: [PATCH 6/7] fix: drop the runner's Chrome apt source before installing PostgreSQL in CI apt-get update fails outright if any configured repo's Release/Packages files disagree, even one we never use - the runner image's preinstalled Google Chrome apt source hit exactly that (a hash sum mismatch on Google's end) and took the postgresql.org repo update down with it, failing the job before PostgreSQL could even be installed. Removing the unused repo first isolates the failure to repos we actually depend on. Co-Authored-By: Claude Sonnet 5 --- .github/workflows/test.yml | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index f3d2b1e..5dfb61d 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -73,7 +73,13 @@ jobs: echo "----- Installing PostgreSQL ${{ matrix.postgres }} -----" sudo sh -c 'echo "deb http://apt.postgresql.org/pub/repos/apt $(lsb_release -cs)-pgdg main" > /etc/apt/sources.list.d/pgdg.list' wget --quiet -O - https://www.postgresql.org/media/keys/ACCC4CF8.asc | sudo apt-key add - - sudo apt-get update + # The runner image ships a Google Chrome apt source we never use here; + # when its upstream Release/Packages files briefly disagree (a known, + # intermittent issue on their end), `apt-get update` fails outright and + # takes the postgresql-org repo update down with it. Drop it first so a + # repo we don't need can't block one we do. + sudo rm -f /etc/apt/sources.list.d/google-chrome.list + sudo apt-get update sudo apt-get -y install postgresql-${{ matrix.postgres }} postgresql-client-${{ matrix.postgres }} echo "----- Configuring -----" sudo cp ./test/_support/pg_hba.conf /etc/postgresql/${{ matrix.postgres }}/main/pg_hba.conf From 41fd4cb6a7a8a5a8d9d457caeecd663f74ac8c13 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Eray=20Hano=C4=9Flu?= Date: Wed, 9 Sep 2026 21:20:59 +0300 Subject: [PATCH 7/7] fix: skip long-cancel-key length assertion below PostgreSQL 18 Protocol 3.2 (and its longer cancel key) only exists on PG 18+; older servers gracefully negotiate back down to 3.0 and hand back the legacy 4-byte key by design, which was failing the assertion on CI's PG 12/16 jobs. --- test/B-connection/17-long-cancel-key.spec.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/test/B-connection/17-long-cancel-key.spec.ts b/test/B-connection/17-long-cancel-key.spec.ts index 64730d8..46bcc20 100644 --- a/test/B-connection/17-long-cancel-key.spec.ts +++ b/test/B-connection/17-long-cancel-key.spec.ts @@ -8,9 +8,17 @@ describe('longCancelKey (protocol 3.2)', () => { if (connection) await connection.close(0); }); - it('should hand back a longer secret key than the 4-byte default', async () => { + it('should hand back a longer secret key than the 4-byte default', async function () { connection = new Connection({ longCancelKey: true }); await connection.connect(); + // Protocol 3.2 (and its longer cancel key) only exists on PostgreSQL + // 18+; older servers gracefully negotiate back down to 3.0 and hand + // back the legacy 4-byte key instead - by design, not a bug. + const serverVersion = parseInt( + connection.sessionParameters.server_version, + 10, + ); + if (serverVersion < 18) return this.skip(); // PostgreSQL sends up to 32 bytes today; the wire format itself // allows up to 256 - either way, strictly more than the legacy 4. expect(connection.secretKey?.length).toBeGreaterThan(4);