Skip to content

core/wal: read raw WAL frames into a buffer the read owns - #10245

Open
turso-github-handyman[bot] wants to merge 1 commit into
mainfrom
auto/9338-wal-get-frame-can-write-into-the-caller--0d4035
Open

turso-github-handyman[bot] wants to merge 1 commit into
mainfrom
auto/9338-wal-get-frame-can-write-into-the-caller--0d4035

Conversation

@turso-github-handyman

Copy link
Copy Markdown

Description

Connection::wal_get_frame could write into the caller's buffer after it had already returned an error. If the IO step() failed while the frame read was queued (io_uring does this when submit_and_wait fails), wait_for_completion returned the error but the read stayed queued. A later step() then wrote the frame into the caller's memory, which the caller may already have freed.

The cause was in WalFile::read_frame_raw: it turned the caller's &mut [u8] into a raw address and the read callback wrote through it with unsafe. Nothing kept that memory alive for as long as the read could still run.

read_frame_raw now takes an Arc<Buffer> that the callback keeps alive, which removes the raw pointer and the unsafe block. Connection::wal_get_frame keeps its public signature: it reads into a temporary buffer and copies into the caller's slice only after the read succeeds. changed_pages_after reuses one owned buffer for all frames.

Motivation and context

Fixes #9338

Description of AI Usage

Autofixer wrote this change with Claude Code in an isolated sandbox. A separate verifier agent tested and reviewed it in its own container before this PR was opened. A maintainer must review it before merge.

To have Autofixer address review feedback, leave your comments and then mention @turso-github-handyman; it handles all unanswered comments from maintainers in one round.

@turso-github-handyman

turso-github-handyman Bot commented Oct 21, 2026 •

Copy link
Copy Markdown
Author

Autofixer merge readiness: ✅ Recommended

  • The independent audit recommends merging this exact patch, and CI is green.

TL;DR: If IO step() failed mid-read, wal_get_frame returned an error but the queued read later wrote into the caller's (maybe freed) buffer. Now the read fills an Arc<Buffer> it owns, and the data is copied to the caller only on success.

Least sure about: core/storage/wal.rs:3954: The callback writes through Buffer::as_mut_slice(&self) on a shared Arc. Is the connection sure not to read buffer until wait_for_completion succeeds? (Yes, as far as I can see.)

How the audit checked it

Change: Invariant violation · Risk if wrong: medium · Behaviour: checked · Audited: c41d452c9c47 (approved)

  • Root cause: WalFile::read_frame_raw passed the caller's &mut [u8] to the IO callback as a raw usize address. Nothing kept that memory alive once the call returned an error.
  • Why the audit recommends it: Narrow memory-safety fix: an owned Arc<Buffer> replaces the raw pointer and the unsafe block, and public Connection API is unchanged. The repro fails on main and passes here. Raw-WAL, wal_api and sync engine tests, clippy and fmt are clean.
  • Summary: Approve: the read now owns its buffer, which removes the use-after-free at the root, and the existing wal_get_frame can write into the caller buffer after it returns an error #9338 repro test checks it. Earlier major finding (no correctness argument): downgraded to minor. The diff touches no lock, commit, visibility or recovery code, and the PR body now explains the cause and the fix.
  • Planned check (from triage): Run the issue's test (cargo test -p core_tester --test integration_tests wal_get_frame_writes_into_buffer_after_it_returns, with db.connect(turso_core::ConnectOpts::new())): on main it panics with 'the read wrote into the buffer after wal_get_frame returned'; after the fix the caller buffer must stay untouched once wal_get_frame has returned an error, and the existing test_wal_api tests must still pass.
  • What the check found: Issue repro (in PR as wal_get_frame_does_not_write_into_buffer_after_it_returns_error): core/ reverted to main fails with 'the read wrote into the buffer after wal_get_frame returned'; with the fix it passes.
  • Reproduction script (written by triage before the fix): on main (76edc7d4d864) bug present; on this PR (1dc598108e91) bug gone
  • What it could affect: Only the raw WAL API reaches this: Connection::wal_get_frame (conn_raw_api, CLI sync_server) and changed_pages_after (sync engine). Normal page reads, commit and checkpoint are untouched.
  • Existing code checked for reuse: fn wal_get_frame (pager) – none found, it is the existing function with
  • Minor: The integrity-tier PR body has no transaction-terms argument. Here it is trivial: no locks, visibility, commit or recovery change; read-only buffer ownership. Fix: Add one sentence saying the change only alters buffer ownership on a read path and affects no transaction or lock state.
Commands the audit ran (7)
  • Merge with main is clean; the patch touches 4 files: git merge --no-edit origin/main; git diff origin/main --stat → merged; 4 files changed, 96 insertions(+), 27 deletions(-)
  • Test fails without the fix and passes with it: git checkout origin/main -- core/ && timeout 300 cargo test -p core_tester --test integration_tests wal_get_frame_does_not_write ; git checkout HEAD -- core/ && (same test) → reverted: FAILED, panicked at test_wal_api.rs:1170; fixed: 1 passed
  • Existing WAL API integration tests pass: timeout 900 cargo test -p core_tester --test integration_tests test_wal_api → 28 passed; 0 failed
  • Raw WAL unit tests (codec, encryption, checksum, wrong size, decode error) pass with all features: timeout 1200 cargo test -p turso_core --all-features --lib raw_wal → 7 passed; 0 failed
  • Sync engine (uses changed_pages_after) unaffected: timeout 900 cargo test -p turso_sync_engine --lib → 117 passed; 0 failed; 1 ignored
  • Lint, format and CLI caller build: cargo fmt --check; cargo clippy -p turso_core --all-features --all-targets -- --deny=warnings; cargo check -p turso_cli → fmt-ok; clippy Finished with no warnings; cli Finished
  • Only one Wal implementation; the simulators never reach the changed API, so simulator seeds would not exercise it: grep -rn 'impl Wal for' core; grep -rln 'wal_get_frame\|wal_changed_pages_after' testing/ → core/storage/wal.rs:3465 only; testing/: no matches

Autofixer's recommendation; a maintainer decides. Updates when the patch, CI, or main changes.

@turso-github-handyman

Copy link
Copy Markdown
Author

Autofixer breaker follow-ups

The breaker found 1 defect that main has too, outside this pull request's scope. They do not block it; each is worth an issue of its own.

  • wal_insert_frame on a frame that already exists: the queued check in write_frame_raw reads the caller's page slice through a raw pointer after the call has returned an error from a failed IO step: Turso gives the raw pointer to the caller slice stays in the queued completion (found by reading the code; not run), invariant gives after an error, no completion should touch caller-owned memory (2026-10-21T00:53:12.752Z)
conn.wal_insert_begin(); io.fail_next_step=true; conn.wal_insert_frame(1, &page_vec) -> Err; drop(page_vec); io.step_one() -> the completion runs from_raw_parts(page_ptr) on freed memory (core/storage/wal.rs:4033,4053)

@turso-github-handyman turso-github-handyman Bot added the autofixer: follow-ups-recommended Autofixer's audit found follow-ups; they are filed as issues when this merges label Oct 21, 2026
@turso-github-handyman
turso-github-handyman Bot marked this pull request as ready for review October 21, 2026 01:25
@turso-github-handyman turso-github-handyman Bot added autofixer: merge-recommended Autofixer's independent audit recommends merging this PR as is and removed autofixer: merge-recommended Autofixer's independent audit recommends merging this PR as is labels Oct 21, 2026
@turso-github-handyman

Copy link
Copy Markdown
Author

Autofixer pushed 8af08fe to rebase onto main.

@turso-github-handyman
turso-github-handyman Bot force-pushed the auto/9338-wal-get-frame-can-write-into-the-caller--0d4035 branch from 1dc5981 to 8af08fe Compare October 25, 2026 06:32
@turso-github-handyman turso-github-handyman Bot added autofixer: merge-recommended Autofixer's independent audit recommends merging this PR as is and removed autofixer: merge-recommended Autofixer's independent audit recommends merging this PR as is labels Oct 25, 2026
@turso-github-handyman

Copy link
Copy Markdown
Author

Autofixer verifier history

Verifiers raised 1 blocker or major finding while Autofixer worked on this pull request. Later rounds may have fixed them; the merge audit re-checks each one.

Findings, oldest first

read_frame_raw handed the IO completion a raw pointer to the caller's
slice. If IO::step() failed while the read was queued (io_uring does this
when submit_and_wait fails), wait_for_completion returned the error but
the read stayed queued, and a later step() wrote into the caller's slice
after wal_get_frame had returned. If the caller had freed that memory,
this was a write to freed memory.

read_frame_raw now takes an Arc<Buffer>, the same way read_frame and
issue_wal_read_into_buffer already do. The completion closure holds a
clone of the Arc, so a read that finishes late writes only into memory
it keeps alive. Connection::wal_get_frame copies that buffer into the
caller's slice only after wait_for_completion succeeds. On error the
caller's slice is left untouched.

Correctness argument:
- Other transactions: the change only affects where the bytes of a WAL
  frame read land in memory. It reads no other frames and changes no
  read marks, WAL locks, or snapshot handling, so what other connections
  see before or after a commit is unchanged.
- Abort and rollback: wal_get_frame is a read and has nothing to undo.
  A failed call leaves the caller's buffer as it was, and the orphaned
  read writes only into its own buffer, which is freed when the
  completion is dropped.
- Crash recovery: nothing is written to the database or WAL file, so a
  crash at any point leaves the on-disk state as it was.
- Locks: no lock is added or taken in a new order, so no deadlock is
  possible. No new state is shared between connections. The buffer
  belongs to one call and its completion.

Tests: wal_get_frame_does_not_write_into_buffer_after_it_returns_error
fails on the base and passes with this change.
@turso-github-handyman

Copy link
Copy Markdown
Author

Autofixer pushed c41d452 to rebase onto main.

@turso-github-handyman
turso-github-handyman Bot force-pushed the auto/9338-wal-get-frame-can-write-into-the-caller--0d4035 branch from 8af08fe to c41d452 Compare October 25, 2026 09:37
@turso-github-handyman turso-github-handyman Bot added autofixer: merge-recommended Autofixer's independent audit recommends merging this PR as is and removed autofixer: follow-ups-recommended Autofixer's audit found follow-ups; they are filed as issues when this merges labels Oct 25, 2026

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

autofixer: merge-recommended Autofixer's independent audit recommends merging this PR as is autofixer core Storage

Projects

None yet

Development

Successfully merging this pull request may close these issues.

wal_get_frame can write into the caller buffer after it returns an error

0 participants