Skip to content

feat(pglite-socket): handle CancelRequest wire protocol message#900

Merged
tdrz merged 1 commit into
electric-sql:mainfrom
bookernath:feat/ssl-cancel-request-handling
Jun 25, 2026
Merged

feat(pglite-socket): handle CancelRequest wire protocol message#900
tdrz merged 1 commit into
electric-sql:mainfrom
bookernath:feat/ssl-cancel-request-handling

Conversation

@bookernath

@bookernath bookernath commented Mar 1, 2026

Copy link
Copy Markdown
Contributor

Summary

Adds handling for the PostgreSQL CancelRequest wire protocol message to pglite-socket.

Note: This PR was originally for both SSLRequest and CancelRequest. SSLRequest handling has since landed on main via #990 (replies 'N'), so this PR has been rebased and now adds only the remaining CancelRequest handling, adapted to match that PR's style (named constants + an extracted method) per review feedback.

  • CancelRequest (code 80877102): consume the 16-byte message and silently ignore it. PGLite has no backend process to signal, and the wire protocol expects no response.

Motivation

CancelRequest, SSLRequest, and StartupMessage are all "untyped" startup-phase messages in the PostgreSQL wire protocol — no type-byte prefix, just [length (int32 BE)][code (int32 BE)][...]. Connection proxies (e.g. Cloudflare Hyperdrive) and standard clients can open a separate connection that begins with a CancelRequest.

Without explicit handling, a CancelRequest falls through to the typed-message parser, which reads byte 0 as a message type and bytes 1–4 as a length. For the 16-byte CancelRequest (00 00 00 10 04 D2 16 2E ...) this yields a bogus oversized length, so the message is buffered indefinitely and the connection stalls / the stream is corrupted. Consuming it explicitly during startup keeps the parser in a sane state.

Changes

File Change
packages/pglite-socket/src/index.ts Add CANCEL_REQUEST_CODE / CANCEL_REQUEST_LENGTH constants and an extracted handleCancelRequest() method, called from the handleData() loop alongside the existing handleSslRequest()
packages/pglite-socket/tests/query-with-node-pg.test.ts Add a CancelRequest resilience test

The implementation mirrors handleSslRequest() from #990 — no magic numbers in the loop, logic extracted into a dedicated method — as requested in review.

Tests

  • CancelRequest resilience: opens a raw TCP socket, sends the 16-byte CancelRequest, confirms the server doesn't crash and still accepts a normal pg.Client connection afterward.

(SSLRequest is now covered by tests/ssl-request.test.ts from #990, so the SSLRequest-specific tests from the original version of this PR were removed.)

Testing done

  • pnpm build — full build passes (ESM + CJS + DTS)
  • pnpm test — 61/61 pass

@bookernath

Copy link
Copy Markdown
Contributor Author

Hey @tdrz @samwillis 👋

Would love your eyes on this when you get a chance. We ran into this while building a unified local dev mode for our platform since we use PGLite via pglite-socket as a zero-config Postgres backend, connected through Cloudflare Hyperdrive (Miniflare's local proxy) for production-parity connection handling.

The issue is that Hyperdrive sends an SSLRequest message during connection setup, and pglite-socket doesn't handle it; the 8-byte SSLRequest falls through to the typed message parser, corrupts the protocol buffer, and crashes the connection. CancelRequest has the same issue.

The fix handles both SSLRequest and CancelRequest as untyped startup-phase messages (responding with 'N' for SSL, silently acking cancel), and adds a startupComplete flag to prevent regular typed messages from being misinterpreted during the handshake phase.

This is a pretty common pattern for Postgres proxies (pgbouncer, Hyperdrive, etc.), so should help anyone trying to put a connection pooler in front of PGLite. All existing tests pass, and I've added 3 new ones for the wire protocol edge cases.

Happy to address any feedback, thanks!

@tdrz tdrz self-requested a review March 18, 2026 13:02
@bookernath

Copy link
Copy Markdown
Contributor Author

@tdrz just following up 🫡

@tdrz

tdrz commented May 21, 2026

Copy link
Copy Markdown
Collaborator

@bookernath Thank you for this!

While I do understand the motivation I am not comfortable with the proposed implementation. PGlite wraps Postgres, which already handles all these cases. Why can't we rely on it to handle them?

@bookernath

bookernath commented May 27, 2026

Copy link
Copy Markdown
Contributor Author

Thanks, fair question. I actually went and tried this before replying. Sent raw SSLRequest and CancelRequest bytes straight into db.execProtocolRaw(...) to see what happens, since if PGlite handles them then the framing layer can just pass them through.

It doesn't. Both come back with:

13 52 00 00 00 0c 00 00 00 05 01 23 45 56

That's an AuthenticationRequest (R), the same response you'd get from a normal StartupMessage. The wire protocol requires SSLRequest to be answered with a single byte (S or N), and CancelRequest to be silently processed with no response and the connection closed. A real libpq client (or Hyperdrive, pgbouncer, etc.) sees the 0x13 length prefix where it expected S/N and either errors out or desyncs.

The reason is structural, as far as I can tell. In real Postgres, SSL negotiation and cancel handling live in the postmaster (ProcessStartupPacket in backend/postmaster/postmaster.c), which does protocol-code dispatch before forking a backend. PGlite compiles only the backend half (interactive_one), so the layer that's supposed to recognize 80877103 / 80877102 and respond accordingly never runs. PGlite just sees an untyped frame and assumes startup.

That's what pushed me to put this in pglite-socket. It's effectively the postmaster in this stack (it owns the TCP listener and the handshake), so framing/handshake responsibility seems to fit there rather than down inside PGlite.

That said, if you'd rather the fix live in PGlite itself (e.g. have execProtocolRaw recognize these two codes and emit the spec response), I'm happy to take a stab at that instead. It would mean any future wire-protocol consumer gets it for free, not just pglite-socket. Just let me know which side you'd rather have it.

Other shape changes I'm happy to make if it lands here:

  1. factor the detection into a small parseStartupFrame() helper so the adapter-level responsibility is more explicit;
  2. drop CancelRequest from this PR and ship SSLRequest only, since Hyperdrive is what actually motivates this;
  3. also close the socket after CancelRequest, which the current implementation skips but the spec calls for.

@tdrz

tdrz commented Jun 16, 2026

Copy link
Copy Markdown
Collaborator

@bookernath I've merged a different PR that was replying 'N' to SSL requests. Please rebase your work and adapt the cancel request response in a way that keeps things understandable (no magic numbers spread through the code, extract logic to a separate function).

@bookernath bookernath force-pushed the feat/ssl-cancel-request-handling branch from 99dc20b to 7977a93 Compare June 21, 2026 22:49
@bookernath bookernath changed the title feat(pglite-socket): handle SSLRequest and CancelRequest wire protocol messages feat(pglite-socket): handle CancelRequest wire protocol message Jun 21, 2026
@bookernath

Copy link
Copy Markdown
Contributor Author

@tdrz ♻️

@tdrz

tdrz commented Jun 22, 2026

Copy link
Copy Markdown
Collaborator

@bookernath Thank you for this!

@tdrz tdrz left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just a small change, the rest looks good! Thank you!

Comment thread .changeset/cancel-request-handling.md Outdated
Rebased onto main, which now handles SSLRequest (replies 'N') via the
extracted handleSslRequest() method (electric-sql#990).

Adapts the remaining CancelRequest handling to that same style as requested
in review: adds named CANCEL_REQUEST_CODE / CANCEL_REQUEST_LENGTH constants
(no magic numbers) and extracts the logic into a dedicated handleCancelRequest()
method called from the handleData() loop alongside handleSslRequest().

PGlite has no backend process to signal, so the request is consumed and
silently ignored (the protocol expects no response). Drops the now-redundant
inline SSL handling and SSL-specific tests (covered by upstream's
ssl-request.test.ts); keeps a CancelRequest integration test.
@bookernath bookernath force-pushed the feat/ssl-cancel-request-handling branch from 7977a93 to 4d8033a Compare June 25, 2026 16:11
@bookernath

Copy link
Copy Markdown
Contributor Author

@tdrz great point, I changed to patch

@tdrz tdrz left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM!

@tdrz tdrz merged commit 29f5617 into electric-sql:main Jun 25, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants