fix(rust/ffi)!: avoid aliasing &mut in cancel shims (soundness) - #33
Open
fornwall wants to merge 1 commit into
Open
fix(rust/ffi)!: avoid aliasing &mut in cancel shims (soundness)#33fornwall wants to merge 1 commit into
fornwall wants to merge 1 commit into
Conversation
fornwall
force-pushed
the
fix/ffi-cancel-aliasing-soundness
branch
from
July 14, 2026 15:22
f443a0c to
5ca7ead
Compare
fornwall
force-pushed
the
fix/ffi-cancel-aliasing-soundness
branch
2 times, most recently
from
July 16, 2026 12:48
8d2bc86 to
b008088
Compare
The ADBC C API allows AdbcConnectionCancel/AdbcStatementCancel to be called from another thread while a call is in flight on the same object, but the exporter shims materialize `&mut` to the exported object from `private_data` in every shim, including the cancel ones. A sanctioned cancel-during-execute therefore created two simultaneously live `&mut` to the same object on two threads, which is undefined behavior. Fix by adding a required `CancelToken` handle to the adbc_core sync traits (`cancel_token`), harvesting the token once at connection/statement init, and storing it in a field disjoint from the driver object. Ordinary shims borrow only the `inner` field; the cancel shims read only the `cancel` field, so no borrows overlap. BREAKING CHANGE: `cancel_token` has no default implementation — every driver must implement it. There is no fallback to the old `cancel(&mut self)` path in the exporter (it was unsound under the sanctioned concurrency); a driver returning `None` gets ADBC_STATUS_NOT_IMPLEMENTED from the cancel entry points instead. Signed-off-by: Fredrik Fornwall <fredrik@fornwall.net>
fornwall
force-pushed
the
fix/ffi-cancel-aliasing-soundness
branch
from
July 16, 2026 12:55
b008088 to
6a5f580
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The bug
The ADBC C API explicitly sanctions calling
AdbcStatementCancel/AdbcConnectionCancelfrom another thread while a call is in flight on the same object — that is cancel's entire purpose:(
adbc.h—AdbcStatementCancel,AdbcConnectionCancel)But the Rust FFI exporter materializes
&'a mut ExportedStatement<DriverType>/&'a mut ExportedConnection<DriverType>fromprivate_datain every shim (statement_private_data,connection_private_data) — includingstatement_cancelandconnection_cancel. So the sanctioned cancel-during-execute sequence creates two simultaneously live&mutreferences to the same object on two threads. That is undefined behavior under Rust's aliasing rules, unconditionally — it does not matter what the code then does with the references.It "happens to work" today because drivers'
cancel()implementations tend to only pokeArc-backed atomics, and current rustc/LLVM don't exploit thenoaliaslicense here. Neither is guaranteed. Miri flags the pattern as UB (see Verification below).Note that changing the trait to
cancel(&self)would not be sufficient: forming even a shared&to the statement in the cancel thread still aliases the executing thread's live&mutover the same bytes. The cancel path must not form any reference to the driver object.The fix: harvested cancel token + per-field borrows
This is the pattern the rest of the ADBC ecosystem already uses — a cancellation handle that is separate from the driver object, harvested at init time:
PGcancel*handle at connect time (stored asPGcancel* cancel_) andCancel()only touches that handle — libpq documentsPGcancelas safe to use from a separate thread while the connection is busy.cancellableContext(a harvestedcontext.CancelFunc) next to the connection/statement, andConnectionCancelonly invokes that func.CConnectionCancel/CStatementCancelto a driverCancel()that concrete drivers implement against such a handle (the framework being C++, thread-safety is the driver's contract rather than a compiler-checked aliasing rule).This PR applies the same shape to the Rust exporter:
adbc_core(core/src/sync.rs): a newCancelTokentrait (fn cancel(&self),Send + Sync) plus a required methodfn cancel_token(&mut self) -> Option<Arc<dyn CancelToken>>onConnectionandStatement.adbc_ffi(ffi/src/driver_exporter.rs):ExportedStatement/ExportedConnectionbecome two-field structs{ inner, cancel }; the token is harvested once on the single-threaded init path (statement_new/connection_init). Then:innerfield through the rawprivate_datapointer (&mut (*exported).inner— a field-place projection; no reference spanning the whole struct is ever formed);cancelfield ((*exported).cancel.clone()) and calltoken.cancel()— never touchinginner's bytes.Disjoint fields reached by field projection from the raw pointer → no overlapping borrows → sound under Stacked/Tree Borrows.
The connection shims also stop taking
&mut FFI_AdbcConnectionon the C-side struct (nowcheck_not_null!+ a raw-pointer helper, matching the statement shims), so the cancel thread's read ofprivate_datadoesn't alias a whole-struct&muteither.cancel(&mut self)— that path was unsound under the sanctioned concurrency, so it is gone rather than kept for compatibility. A driver whosecancel_token()returnsNonegetsADBC_STATUS_NOT_IMPLEMENTEDfromAdbcConnectionCancel/AdbcStatementCancel(the C API allows cancel to be unimplemented).Connection::cancel/Statement::cancelremain on the traits as the consumer-facing API (sound where&mutis exclusive, e.g. through the driver manager over the C API).adbc_driver_manager'sManagedConnection/ManagedStatementimplementcancel_tokenas an explicitNonewith a comment: every C driver call there is serialized behind a mutex, so a genuinely concurrent token needs a locking redesign (out of scope here; theircancel(&mut self)keeps calling the thread-safe C cancel as before).Breaking change (0.x)
cancel_tokenhas no default implementation — every driver implementing theadbc_coresyncConnection/Statementtraits must add it. Return a real token to keep cancel working through the FFI exporter (the compiler now forces the choice to be explicit); returnNoneif the driver cannot support concurrent cancellation, which surfaces as NOT_IMPLEMENTED to C consumers instead of silently-UB fallback behavior.Verification
In-process test
rust/ffi/tests/cancel_during_execute.rs: a minimal driver whoseexecute_update/get_table_typesblock until the cancel token fires, driven through the exported C function pointers, with the cancel shim invoked from a second thread while the executing shim is blocked inside the driver — i.e. exactly the sanctioned concurrency. The driver objects are deliberately non-ZST and touched on both sides of the blocking call so the aliasing is observable. A second test drives a tokenless configuration of the same driver (via a database option) and asserts both cancel entry points returnADBC_STATUS_NOT_IMPLEMENTED.cargo +nightly miri test -p adbc_ffi --test cancel_during_executepasses (both tests) with this fix.cancel(&mut self)reached via&mutfrom the cancel shim) makes Miri report the expected aliasing UB:not granting access to tag <…> because that would remove [Unique for <…>] which is strongly protected— the executing thread's protected&mut selfvs. the cancel thread's&mut. That path no longer exists in the exporter.driver_exporter_dummyround-trip tests (23) pass, now via the token path, including thetest_connection_cancelerror round-trip.Checks run
From
rust/(branched from this fork'smain= apache/arrow-adbcmain@9be0298;rust/is identical there to rev198f39athat downstream currently pins):cargo fmt --all --check— cleancargo clippy -p adbc_core -p adbc_ffi -p adbc_dummy --all-targets --all-features -- -Dwarnings— clean;cargo clippy -p adbc_driver_manager --lib -- -Dwarnings— cleancargo test --all-targets --workspace --no-fail-fast— all green except the pre-existing/environmental items belowcargo test --doc,RUSTDOCFLAGS=-Dwarnings cargo doc --no-deps -p adbc_core -p adbc_ffi -p adbc_driver_manager— clean except belowPre-existing on the unmodified base (not introduced here, left untouched):
cargo clippy -p adbc_driver_manager --all-targets -- -Dwarningsfails with dead-code lints indriver_manager/tests/common/mod.rs(local stable 1.97).driver_manager_sqlitetest suite and oneadbc_driver_managerdoctest requirelibadbc_driver_sqlite.so(installed by CI, absent locally).🤖 Generated with Claude Code
https://claude.ai/code/session_01MK2VgNRk3cwLigCWwf2yBL