Skip to content

turnloop-postgres: host API gaps from Perry's pg binding (#57–#62) - #99

Merged
proggeramlug merged 7 commits into
mainfrom
fix/postgres-host-api
Sep 22, 2026
Merged

proggeramlug merged 7 commits into
mainfrom
fix/postgres-host-api

Conversation

@proggeramlug

Copy link
Copy Markdown
Contributor

Six fixes to turnloop-postgres from Perry's P7 lane (the pg binding). One commit per issue.

Breaking changes

  • Error::Transport is now a tuple variant (Error::Transport(None) for the old meaning).
  • New Value::Unknown variant: text values of OIDs with no codec, including "char" (OID 18), no longer match Value::Text.
  • A host must answer ScramNeeded before pulling events again.

Checks

Local: fmt, workspace clippy, workspace doc, and cargo test -p turnloop-postgres --all-features all pass. The local MSRV check didn't finish because the disk filled up, and the real-server tests (edited for #57/#60) need the CI fixture; CI covers both.

Closes #57
Closes #58
Closes #59
Closes #60
Closes #61
Closes #62

Every event from `next_event` borrows the connection's input buffer until the
next mutable call. `types::Value::into_owned` existed, but `Row` yields
`Result<Option<&[u8]>>` and `Fields` yields `Field<'a>`, so every host that
wants to keep a row, a field list or a diagnostic wrote the same ~80-line
layer copying them out value by value.

`Event::into_owned()` now returns an `OwnedEvent`, mirroring `Event` with its
borrowed payloads copied out. Rows, field lists and server errors become
`OwnedRow`, `OwnedFields` and `OwnedServerError`, also reachable directly as
`Row/Fields/ServerError::into_owned()`. Each is one copy of the already
validated wire bytes (one allocation, like `ConnectionFailure`), and each
lends back the existing borrowed iterator (`row()`, `fields()`,
`server_error()`), so no second decoding path exists. `OwnedRow::get` indexes
a column directly. `OwnedEvent::as_event()` lends a retained event back as a
borrowed `Event`, so a host handler written for one form consumes both. The
borrowed, allocation-free path is unchanged.

The new protocol test retains fields, a row with a NULL, a notice and a
notification, then feeds a second batch whose `receive` compacts the input
buffer over the bytes those events borrowed, and asserts every owned value
(and the `as_event` round trip) is unchanged.

Closes #58
`Connection::new` consumes the `Config`, and nothing handed the password back.
The README asks the host to build `ScramSha256` when `ScramNeeded` fires, so
every host kept its own second copy of the database password purely to feed
that constructor; the bundled async client did exactly that with a clone.

The issue offers two fixes. This takes the accessor, `Connection::config()`,
rather than `start_scram(password, binding)`. The upstream
`ScramSha256::new` is the only public constructor and draws its nonce from
`rand::rng()`; building it inside the core would make the core read entropy,
breaking this crate's stated sans-I/O guarantee (it "never ... generates
entropy") and the host-supplied entropy wiring for wasm32-unknown and WASI p3.
The accessor removes the duplicated credential while the entropy read stays in
the host, and it also exposes the rest of the effective configuration. The
password was already retained inside the connection for cleartext and MD5
authentication, so nothing new is kept.

The async client no longer clones the password, and the real-server fixture no
longer stores one. The SCRAM protocol test now configures the password only in
`Config`, builds `ScramSha256` from `config().password`, and completes both
plain and PLUS exchanges whose server signatures are derived from "secret"
(and still rejects the corrupted verifier).

Closes #57
`abort(reason)` could only report a transport failure as the unit
`Error::Transport`, which always displays "Connection terminated
unexpectedly". The real cause the host had in hand when it aborted (say
`connect ECONNREFUSED 127.0.0.1:5432`) had nowhere to go inside the core's own
`Outcome::Aborted` completions, so every host shadowed it in state of its own.

`Error::Transport` now carries `Option<TransportFailure>`: the host's
`io::ErrorKind`, an optional error code (OS errno or libuv-style code) and a
message. `Error::transport(&io::Error)` builds it from an I/O error, keeping
`raw_os_error`; `TransportFailure::new` takes the three parts directly. The
diagnostic reaches every pending token's `Outcome::Aborted` and the `Closed`
reason, displays as "Connection terminated unexpectedly: <message>", and
converting to `io::Error` preserves the host's kind instead of flattening it
to `ConnectionAborted`. The message is an `Arc<str>`, so, like
`ConnectionFailure`, fanning one abort out to a pipeline allocates nothing.
`Error::Transport(None)` keeps the old generic message and remains the
default reason.

Breaking: `Error::Transport` is now a tuple variant; existing
`Error::Transport` becomes `Error::Transport(None)`.

Tests: a protocol test aborts three pending tokens with an io::Error-derived
and an explicitly coded diagnostic and asserts kind, code and message on every
completion and the close, plus Display and io::Error conversion; the
allocation test fans a coded diagnostic out to 64 tokens with zero
allocations.

Closes #59
`types::decode` fell through to `Value::Text` for any text-format OID it had
no codec for, the same variant a real text/varchar column produces. A host
matching on the variant, the natural way to consume the API, could not tell
"this is text" from "no codec, here is the string", and silently widened its
own type surface: time, interval, inet, oid, xml or an enum all looked like
text columns.

Text-format values now become `Value::Text` only for the OIDs the binary codec
also treats as text (name 19, text 25, bpchar 1042, varchar 1043); everything
else becomes `Value::Unknown { oid, text }`, the text-format counterpart of the
existing binary `Value::Raw { oid, bytes }`. It stays borrowed and
allocation-free, and `into_owned` covers it.

Breaking: `Value` has a new variant, and text-format values of OIDs without a
codec (including the single-byte `"char"`, OID 18, already `Raw` in binary)
no longer match `Value::Text`.

The new unit test pins the four text OIDs to `Text`, five codec-less OIDs
(time, interval, inet, oid, an extension OID) to `Unknown` with their OID and
text, including through `into_owned`, and binary unknowns to `Raw`. The
real-server conversion test now also rejects `Unknown` for every type it
claims a codec for.

Closes #60
After `ScramNeeded`, `next_event()` returned `Ok(None)` in `State::Scram`,
the same value a host sees when there is simply no input yet. A host that did
not specifically handle `ScramNeeded` therefore stalled silently: no error,
no event, a connection that looks alive but never authenticates.

Pulling in that state now returns `Error::State("SCRAM authentication
pending: answer ScramNeeded with start_scram")`. The error was chosen over
re-emitting `ScramNeeded`: a host that ignores the event would spin forever on
a repeated event, whereas an error ends its pull loop and names the missing
call. The state is left untouched, so a host that pulls early can still call
`start_scram` (or `abort`). Hosts that answer the event before pulling again,
including the bundled async client (the driver returns after each event), are
unaffected.

Behaviour change: a host that collected events in a pull loop and called
`start_scram` only after the loop drained must now answer `ScramNeeded` before
its next pull.

The legacy-TLS SCRAM test now pulls twice before answering, asserts the exact
error both times and that no output was produced, then completes the SCRAM
initial response as before. Without the fix both pulls return Ok(None).

Closes #61
`Connection::new` queues the StartupMessage (or, with TLS, the SSLRequest)
before the host has a transport, so `output()` is non-empty at construction.
The README described setup as ordered steps (build the connection, then hand
it a transport) without saying step 1 already produced output, so a host that
assumed `output()` starts empty until it drives the connection would never
send those bytes and wait forever for a server reply.

The README's first step now states that construction already queues output
and that the host must transmit it first once connected. `Connection::new`
gains a doc comment saying the same, with a doctest asserting the exact
queued bytes: a StartupMessage carrying the `user` parameter, and the 8-byte
SSLRequest for `SslMode::Require`. No behaviour changes.

Closes #62
@coderabbitai

coderabbitai Bot commented Sep 22, 2026 •

Copy link
Copy Markdown

Warning

Review limit reached

Next included review available in 42 minutes.

Check out review usage here.

View limit details

Limit details: You’ve used the included review currently available.

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

Learn how review limits work.

Review configuration:

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Advanced

Run ID: b60011ba-6cef-47d5-b566-84d73ecbdcc9

📥 Commits

Reviewing files that changed from the base of the PR and between cfc9b87 and 733256e.

📒 Files selected for processing (8)
  • protocols/turnloop-postgres/README.md
  • protocols/turnloop-postgres/src/client.rs
  • protocols/turnloop-postgres/src/lib.rs
  • protocols/turnloop-postgres/src/types.rs
  • protocols/turnloop-postgres/src/wire.rs
  • protocols/turnloop-postgres/tests/allocations.rs
  • protocols/turnloop-postgres/tests/protocol.rs
  • protocols/turnloop-postgres/tests/server.rs

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@proggeramlug
proggeramlug merged commit 34ac84a into main Sep 22, 2026
39 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment