sql: consolidate per-driver StackReader into shared/ - #32141
Conversation
The MySQL and Postgres StackReader types were near-identical cursor-over-buffer wrappers. This moves the struct + read methods to shared/StackReader.rs with two small traits (ShortRead, WrapReader) so each driver supplies its own error variant and NewReader wrapper. The per-driver StackReader.rs files become trait impls + a re-export. Also drops the dead NewReaderOf type alias and decode_allocator (which was identical to decode) from mysql NewReader.rs.
|
Updated 4:23 PM PT - Jun 11th, 2026
❌ @alii, your commit 5e04e03 has 1 failures in
🧪 To try this PR locally: bunx bun-pr 32141That installs a local version of the PR into your bun-32141 --bun |
WalkthroughThis pull request extracts buffer cursor management into a shared ChangesShared StackReader Extraction and Protocol Integration
Suggested reviewers
🚥 Pre-merge checks | ✅ 4✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. Comment |
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@src/sql/shared/StackReader.rs`:
- Around line 62-64: The cursor math currently uses unchecked addition and an
expect-based int cast which can panic; update set_offset_from_start (and the
other shared-reader paths that add offsets around the code handling
message_start + offset and the additions at lines ~85-91) to perform bounds-safe
arithmetic (e.g., use checked_add or saturating_add and then clamp to
buffer.len() or return a Result instead of panicking) and replace the
expect("int cast") at the cast site with a fallible conversion (usize::try_from
or to/from conversions returning a Result) and handle the Err case without
unwrap (clamp, return Err, or no-op) so all external-data index/length math
maintains offset <= buffer.len() and never panics; reference the fields/methods
message_start, offset.set, and the specific int-cast site when making changes.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: ASSERTIVE
Plan: Pro
Run ID: 1cfa5fea-9f93-4519-8852-ab321de68a6d
📒 Files selected for processing (6)
src/sql/lib.rssrc/sql/mysql/protocol/NewReader.rssrc/sql/mysql/protocol/StackReader.rssrc/sql/postgres/protocol/NewReader.rssrc/sql/postgres/protocol/StackReader.rssrc/sql/shared/StackReader.rs
💤 Files with no reviewable changes (1)
- src/sql/mysql/protocol/NewReader.rs
There was a problem hiding this comment.
I didn't find any issues — the Cell::from_mut roundtrip for the postgres caller checks out and the dead-code removals have zero remaining references — but since this swaps the cursor representation on the per-row decode hot path for both SQL drivers, it's worth a quick human look.
Extended reasoning...
Overview
Consolidates the two per-driver StackReader cursor types (mysql + postgres) into a single shared implementation in src/sql/shared/StackReader.rs, parameterized by three small traits (ShortRead for the error variant, WrapReader for the NewReader<C> wrapper, IntoCursor to accept either &Cell<usize> or &mut usize). The per-driver StackReader.rs files become thin trait impls + a re-export. Also removes two dead items from mysql/protocol/NewReader.rs (NewReaderOf alias and Decode::decode_allocator). Net −29 lines across 6 files; one comment-only change in postgres/protocol/NewReader.rs.
Security risks
None. This is a pure internal refactor of buffer-cursor arithmetic with no input-handling, auth, or permission changes. The two behavioral deltas on the postgres side (checked_add in ensure_capacity, and skip clamping when count > isize::MAX) move in the strictly-safer direction vs. the old release-mode wrap.
Level of scrutiny
Moderate. The diff is mechanical and the equivalence argument in the PR body is thorough and accurate — I traced the key concern (postgres caller at PostgresSQLConnection.rs:982 passes &mut consumed/&mut offset, reads them back after a short-read; Cell::from_mut is a transparent reborrow so the writes land in the original locals) and it holds. However, this is the per-row wire-protocol decode path for both SQL drivers, the postgres cursor representation changes from &mut usize to &Cell<usize>, and init now relies on return-type inference through a new generic R: WrapReader. That's enough new abstraction on a hot path that a human glance is warranted rather than a bot rubber-stamp.
Other factors
cargo check/clippyreported clean by the author.- Verified zero remaining references to
NewReaderOf/decode_allocatorvia grep. - Verified the two
StackReader::initcall sites (MySQLConnection.rs:488with&Cell,PostgresSQLConnection.rs:982with&mut usize) both map cleanly throughIntoCursor. - No CODEOWNERS entry for
src/sql/. - No prior reviews on this PR; only the robobun build comment in the timeline.
…n in shared.ts (#32145) ## What this does Final slice from #31994 (closed as too big), on top of #32128, #32135, #32141. Consolidates the JS adapters' duplicated pool/connection/query plumbing from `src/js/internal/sql/{postgres,mysql,sqlite}.ts` into `src/js/internal/sql/shared.ts` as a `BasePooledConnection` class hierarchy. Net −995 lines across 4 files. Two commits: - **Cherry-pick from #31994** (1aa3dbb) — checks out `src/js/internal/sql/` from `origin/claude/split/sql` as-is. This intentionally clobbers two pool-lifecycle fixes that landed on `main` since #31994's merge-base: #32041 (throwing onconnect/onclose corrupting the pool) and #32097 (forced `close()` resolving mid-handshake). - **Re-integrate #32041 + #32097 into the new structure** (6e6fd4f) — re-hosts both fixes in `BasePooledConnection`: - #32041: try/finally around the user `onconnect`/`onclose` calls in `handleConnected` and `#finishClose` so a throwing callback never skips pool bookkeeping; the `createPooledConnectionHandle` catch always defers via `process.nextTick` (drops the per-driver `deferSyncCloseError` flag — both drivers now match). - #32097: `startConnection()` is `abstract Promise<void>` and both subclasses await + assign `this.connection` at creation; `#beginConnecting` awaits it and closes the handle if `onFinish` was set (pool force-closed) in the microtask window before it materialized. ## Behavioral equivalence The original `BasePooledConnection` extraction in #31994 carried a per-file behavioral-equivalence audit against its merge-base (placeholders, escaping, helper commands, error message text, pool/transaction semantics all preserved) and was green on full CI build 61383. This PR re-applies that diff and adds the two missing fixes; the load-bearing verification is below. ## Verification - `bun bd`: builds clean. - The three lifecycle test files (`sql-onconnect-onclose-throw.test.ts`, `sql-close-pending-connection.test.ts`, `sql-connect-error-reporting.test.ts`): 26 pass / 0 fail. - **New tests in this PR**: `sql-onconnect-onclose-throw.test.ts` gains a forced-close variant per driver (a throwing `onclose` while the pool is force-closed mid-handshake, against a fake `net` server). The two re-hosted fixes meet in `BasePooledConnection`'s close handler, and that interaction had no coverage. Note these pass against `origin/main`'s `src/js/internal/sql` too (verified): both underlying fixes already live on main per-driver, so versus main this PR is equivalence-preserving by design and no test can fail on one side only. The HEAD~1 stash test below is the proof that the re-integration commit is load-bearing. - **Load-bearing stash test**: built and ran the same three files at HEAD~1 (the cherry-pick before re-integration) — 6 fail (`mysql: forced close() resolves when called before the native handle is stored` timeout; `postgres: pool calls from onclose are safe when connecting fails synchronously` → `reentry threw: TypeError`; both drivers' `throwing onclose still rejects pending queries on connect refused` timeouts). With the re-integration: 26 pass. The diff is required for both fixes. - Full `test/js/sql/` against live `postgres_plain`/`mysql_plain`/`*_tls` containers: 1526 pass / 7 fail / 6 errors locally. Every failure reproduces on `origin/main` in the same environment — see Notes below. ## Notes on local-only test failures (none introduced by this PR) - `sql-mysql-query-string-leak.test.ts` fails locally on a debug+ASAN build for both `origin/main` (314.6 MiB) and this branch (317.2 MiB), within noise of each other; both exceed the 256 MiB ASAN threshold. A `heapStats()` probe of the same workload shows `MySQLQuery 2→1` after GC — wrappers are finalized correctly. The test passes on `main`'s CI (release build); the threshold is tuned for release RSS overhead, not local debug. - `sql.test.ts > query string memory leak test` (postgres): same category. - `sqlite-sql.test.ts > Query Normalization Fuzzing Tests > handles exotic but valid SQL patterns`: 5.5s timeout on debug builds — pre-existing per #31994's original verification notes. - `describeWithContainer` cold-start races: `docker compose up -d --wait` returns non-zero on an already-healthy container (`test/docker/index.ts:157`), causing beforeEach hook timeouts in `sql-mysql.test.ts` (TLS), `sql-mysql-bind-oob`, `sql-mysql.helpers`, `sql-mysql.auth`. Pre-existing harness flake; clears on warm rerun. The Rust-side changes from #32097 (`src/sql_jsc/`) were already on `main` before this PR — only the JS hooks needed re-integrating.
What this does
Third (and last Rust) slice from #31994, on top of #32128 and #32135. Consolidates the per-driver
StackReadercursor type intosrc/sql/shared/StackReader.rs. Net −29 lines across 6 files.StackReaderis the cursor-over-borrowed-buffer that both drivers'NewReadertypes wrap on the per-row decode path. The MySQL and Postgres copies were near-identical; the only per-driver bits were the error variant returned on a short read and theNewReader<C>wrapper type. Those become two small traits (ShortRead,WrapReader) that each driver implements in ~10 lines; the per-driverStackReader.rsfiles are now those impls plus a re-export.Also drops two dead items from
mysql/protocol/NewReader.rs: theNewReaderOf<C>type alias (=NewReader<C>, zero callers) andDecode::decode_allocator(body identical todecode, zero callers).Behavioral equivalence
&Cell<usize>;skipnegative-count branch viasaturating_subis identical to the old explicit clamp.&mut usize; new shared uses&Cell<usize>for both.IntoCursor for &mut usizeisCell::from_mut(arepr(transparent)pointer cast), so the existing&mut consumed/&mut offsetcall site atPostgresSQLConnection.rs:982is unchanged and reads back the same locals after the reader is consumed.ShortRead::SHORT_READmonomorphizes to the literalAnyMySQLError::ShortRead/AnyPostgresError::ShortRead— identicalErrvalues to before.Two strictly-safer deltas on the postgres side, both unreachable on real wire data but the correct direction:
ensure_length/ensure_capacityusechecked_addwhere the oldbuffer.len() >= offset + lengthcould overflow-wrap in release on adversarial declared lengths.skip(count > isize::MAX)clamps to buffer end instead of overflow-wrapping.Performance
No new dynamic dispatch or boxing —
ShortRead/WrapReader/IntoCursorare all generic-bound.Cell::get/setonusizecompiles to plain load/store. The per-row hot path (DataRow→NewReader::int→wrapped.read(N)) is slightly leaner: sharedreadadvances the cursor withoffset.set(offset + count)instead of routing throughskip()with its redundant bounds re-check. The one addedisize::try_from().unwrap_or()in the postgresskipbridge is not on the row path (skipis called only fromFieldDescriptiononce per column definition andAuthenticationonce per connection).Verification
cargo check -p bun_sql -p bun_sql_jscandcargo clippy --no-deps: clean.cargo fmt: no diffs.NewReaderOf/decode_allocatorvia grep.Left for a follow-up
The
src/js/internal/sql/shared.tsJS adapter consolidation from #31994 (BasePooledConnection, +1308/−2273) — the higher-risk pool/lifecycle piece.