feat: catch panics at the FFI boundary and report LANCE_ERR_PANIC (#61) - #62
Open
u70b3 wants to merge 9 commits into
Open
feat: catch panics at the FFI boundary and report LANCE_ERR_PANIC (#61)#62u70b3 wants to merge 9 commits into
u70b3 wants to merge 9 commits into
Conversation
Convert every remaining manual set/clear_last_error entry point to the catch_unwind-backed ffi_try! macro so an unwinding panic anywhere in a fallible extern "C" body is mapped to the shape's error return plus a thread-local LANCE_ERR_PANIC carrying the panic payload, instead of crossing the FFI boundary. - error.rs: add a generic ($body, $errval) fallback arm after the null/neg/void literal arms (literal tokens match first, so the expr catch-all never shadows them); serves the 12 zero-sentinel integer returns. Unit tests cover Ok/Err/panic paths, including a non-zero sentinel to prove $errval is returned verbatim. - dataset.rs: version, count_rows, latest_version, fragment_count -> ffi_try!(.., 0); fragment_ids (the lone non-ffi_try! neg-int) -> ffi_try!(.., neg). Deliberate minor bugfix: lance_dataset_version now clears the thread-local error on success like every other guarded fn (it previously left stale errors behind). - data_statistics.rs / versions.rs: count + *_at accessors -> ffi_try! (.., 0); entry_at helpers now return Result instead of setting the thread-local error and returning Option. - index.rs: index_count, index_segment_count -> ffi_try!(.., 0) (the not-found case now flows through Error::index_not_found, still mapped to LANCE_ERR_NOT_FOUND); index_list_json -> ffi_try!(.., null), preserving the interior-NUL -> NULL-without-error quirk. - scanner.rs: the 10 simple setters -> ffi_try!(.., neg); the scanner_set_u32! macro guards inside the macro so set_nprobes / set_refine_factor / set_ef are covered at once. - writer.rs: lance_dataset_write wraps write_dataset_inner directly instead of tail-calling the lance_dataset_write_with_params extern — both extern frames get their own guard, and an outer guard around the forwarding call would have cleared the error the inner guard set. Validation order, error codes, and message payloads are unchanged on the Ok/lance-Error paths. Out of scope per plan: lance_scanner_next, lance_scanner_poll_next, lance_scanner_to_arrow_stream, lance_scanner_scan_async, the void close/free entry points, and src/async_dispatcher.rs (later steps of issue lance-format#61).
…ing contract Guard the six void close/free entry points with swallow_unwind: lance_dataset_close, lance_scanner_close (drops a possibly-live DatasetRecordBatchStream — the highest-risk Drop), lance_batch_free, lance_data_statistics_close, lance_versions_close, lance_free_string. A panicking Drop is logged via log::error! and never propagates; the remainder may leak, which is the documented best-effort close policy (issue lance-format#61 point 6). No poison checks on close paths: a poisoned handle must still be freeable. The two remaining 'no error channel' entry points from the audit, lance_last_error_code and lance_last_error_message, are left unguarded by justification: both are plain TLS read/take operations whose RefCell borrows never overlap, so they cannot panic in practice (the only theoretical vector is TLS access during thread destruction, which is outside the API contract). lance_last_error_code additionally returns the enum by value, so a guard could only report a panic as LANCE_OK and actively mask the very errors the host is trying to read. Documentation: - lance.h: 'Panic handling' block next to LanceErrorCode describing the strategy (catch at the boundary -> LANCE_ERR_PANIC; scanner poison; dataset handles stay usable because commits are atomic manifest swaps) and the honest limits (double panics, Drop-during-unwind panics, stack overflow, and allocation failure still abort; close/free is best-effort and may leak; hosts should fail the query rather than retry a poisoned handle). - lance.h: refreshed LanceCallback, lance_scanner_scan_async, and lance_scanner_to_arrow_stream docs for dispatcher-thread TLS error delivery, the no-panic callback rule, and the exported-stream panic contract (one error via nonzero get_next + get_last_error, then end-of-stream, scanner poisoned). - src/error.rs: module rustdoc summarizing the strategy and limits. - README.md / lance.hpp: checked, no stale error-handling docs (the C++ side carries LanceErrorCode through check_error() unchanged).
u70b3
marked this pull request as draft
August 20, 2026 09:14
Address the lance-gatekeeper review, all four findings: - The exported-stream guard moves from the stream's poll_next up to a reader-level GuardedReader wrapping the complete handle.block_on(stream.next()) operation: Handle::block_on panics before any poll when the consumer's thread is driving a Tokio runtime, and a stream-level catch could never observe that. A caught panic still maps to exactly one terminal error item + EOS and poisons the owning scanner. - GuardedReader::drop detaches the inner stream and drops it under catch_unwind, so a cleanup panic on arrow-rs's extern "C" release path is contained (best-effort leak) instead of aborting the host. - lance_scanner_scan_async gains an entry guard around the whole call-time setup (validation, build_scanner, runtime access, spawn): a setup panic now poisons the handle and dispatches exactly one LANCE_ERR_PANIC completion instead of unwinding out of the void extern "C" entry point. The setup closure is injectable for tests. - The dispatcher's callback catch_unwind is no longer claimed to contain panicking callbacks: the declared LanceCallback ABI is extern "C" (non-unwinding), so such a panic aborts at the callback's own boundary first. The C-unwind transmute test is removed, lance.h states the non-unwinding callback contract explicitly, and the catch stays as documented best-effort protection for C-unwind hosts only. New regressions (child-process, through the raw Arrow C pointers): get_next on a runtime-driving thread survives with the stream error contract; release with a panicking destructor survives; a scan_async setup panic poisons the handle and dispatches exactly one completion. Full suite: 296 passed / 0 failed; clippy -D warnings and fmt clean.
u70b3
marked this pull request as ready for review
August 21, 2026 12:08
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.
Summary
POC implementation of the panic-handling strategy from #61 (consensus of @jja725, @zhangstar333, @u70b3 in the issue discussion):
panic = "abort"from the release profile. Under Rust 1.81+ an unwinding panic out of anextern "C"fn is a defined abort, never UB — any path we miss can no longer be unsound, only an abort.catch_unwind— 54 via the extendedffi_try!arms (null/neg/void/ generic errval, covering the 0-sentinel integer shape), 3 hand-rolled guards on the scanner stream ops, an async task guard plus an entry guard around the async entry's whole call-time setup, 6swallow_unwindclose/free paths, and 2 provably infallible TLS accessors. Caught panics map to the newLANCE_ERR_PANIC = 9(ABI-compatible enum append; matches the header'sLANCE_ERR_*convention), with the message extracted UniFFI-style (&str/Stringdowncast, NUL-sanitized).Arc<AtomicBool>shared with the exported-stream wrapper and the async task; laterlance_scanner_*calls fail withLANCE_ERR_PANIC("scanner is poisoned by an earlier panic").lance_scanner_closestays poison-check-free so memory is always freed.RwLock-poisoning catch):with_mutis now clone-execute-swap under the write lock withcatch_unwind+resume_unwind— the panic keeps its original payload for the entry guard, in-memory state rolls back by never swapping, and lock access is poison-tolerant (a singleArcpointer swap can never tear).GuardedReaderwhosenext()wraps the completehandle.block_on(stream.next())operation — a stream-level guard catches too late, becauseHandle::block_onitself panics before any poll when the consumer's thread is driving a Tokio runtime. A mid-iteration panic becomes exactly oneErritem — which arrow-rs's exportedget_nextmaps to a nonzero return +get_last_error, then EOS, i.e. the Arrow C stream error contract with no C-side changes — and poisons the owning scanner.GuardedReader::dropdetaches the inner stream and drops it undercatch_unwind, so a cleanup panic on arrow-rs'sextern "C"release path is contained (best-effort leak) instead of aborting the host. Worth upstreaming intolance_io::ffi::to_ffi_arrow_array_streamso lance-java benefits.DispatcherMessagenow carries(code, message), installed on the dispatcher thread's TLS immediately before each callback (fixes async errors dying on a Tokio worker's TLS — credit @zhangstar333); the whole spawned future runs undercatch_unwind().await, so a task panic still delivers the-1callback instead of hanging the C caller, and poisons the scanner; and the entry guard around call-time setup (validation, scanner building, runtime access, spawn) delivers the same-1+ poison even when setup itself panics. Host callbacks are explicitly non-unwinding by contract (lance.h): a panickingextern "C"callback aborts at its own boundary before any caller-side guard can run, so the dispatcher'scatch_unwindis only best-effort protection forextern "C-unwind"Rust hosts — never part of the contract.lance.h): double panics, panics inDropduring unwind, stack overflow, and allocation failure still abort; close/free (including Arrow streamrelease) is best-effort and may leak the remainder; hosts should fail the query rather than retry a poisoned handle; callbacks passed into the library must not panic.Evidence / tests
tests/panic_stream_guard.rs(the PoC from the issue discussion, now tracked): an unguarded export dies by SIGABRT in a child process even underpanic = "unwind"; the guarded export drives the full Arrow C stream error contract end to end through rawget_nextpointers — including whenget_nextis called from a thread driving a Tokio runtime (whereHandle::block_onpanics before any poll) — and a panicking destructor reached through the rawreleasepointer is contained. Both child-process regressions assert a clean exit AND that the panic really fired (via the panic hook's stderr).with_mutrollback on a genuinely poisoned lock, dispatcher TLS transport + stale-error clearing, reader-level fusing / NUL sanitization / runtime-driving-thread containment / drop containment, and an injectable-setup test proving ascan_asyncsetup panic poisons the handle and dispatches exactly oneLANCE_ERR_PANICcompletion.cargo clippy --all-targets -- -D warningsclean;cargo fmtclean; C/C++ compile-and-run tests pass (GCC 13.3.0).Deliberate minor bugfixes folded in
lance_dataset_versionnow clears the thread-local error on success, like every other guarded fn.lance_dataset_writewraps the shared inner directly instead of bare-forwarding tolance_dataset_write_with_params(an outer guard would have cleared the inner guard's TLS error).Out of scope / follow-ups
src/index_segment.rs) live on the unmerged distributed-index branch; they need the same poison treatment once that lands.GuardedReaderinto lance-io so lance-java benefits too.