Skip to content

feat: catch panics at the FFI boundary and report LANCE_ERR_PANIC (#61) - #62

Open
u70b3 wants to merge 9 commits into
lance-format:mainfrom
u70b3:feat/panic-unwind-guards
Open

feat: catch panics at the FFI boundary and report LANCE_ERR_PANIC (#61)#62
u70b3 wants to merge 9 commits into
lance-format:mainfrom
u70b3:feat/panic-unwind-guards

Conversation

@u70b3

@u70b3 u70b3 commented Aug 20, 2026

Copy link
Copy Markdown
Contributor

Summary

POC implementation of the panic-handling strategy from #61 (consensus of @jja725, @zhangstar333, @u70b3 in the issue discussion):

  • Drop panic = "abort" from the release profile. Under Rust 1.81+ an unwinding panic out of an extern "C" fn is a defined abort, never UB — any path we miss can no longer be unsound, only an abort.
  • Catch at every FFI boundary: all 66 entry points now run under catch_unwind — 54 via the extended ffi_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, 6 swallow_unwind close/free paths, and 2 provably infallible TLS accessors. Caught panics map to the new LANCE_ERR_PANIC = 9 (ABI-compatible enum append; matches the header's LANCE_ERR_* convention), with the message extracted UniFFI-style (&str/String downcast, NUL-sanitized).
  • Poisoned scanner handles: a caught panic flips an Arc<AtomicBool> shared with the exported-stream wrapper and the async task; later lance_scanner_* calls fail with LANCE_ERR_PANIC ("scanner is poisoned by an earlier panic"). lance_scanner_close stays poison-check-free so memory is always freed.
  • Dataset handles stay usable (credit @zhangstar333 for the RwLock-poisoning catch): with_mut is now clone-execute-swap under the write lock with catch_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 single Arc pointer swap can never tear).
  • Exported Arrow streams (the issue's central gap): the stream is exported through a reader-level GuardedReader whose next() wraps the complete handle.block_on(stream.next()) operation — a stream-level guard catches too late, because Handle::block_on itself panics before any poll when the consumer's thread is driving a Tokio runtime. A mid-iteration panic becomes exactly one Err item — which arrow-rs's exported get_next maps 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::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. Worth upstreaming into lance_io::ffi::to_ffi_arrow_array_stream so lance-java benefits.
  • Async path: DispatcherMessage now 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 under catch_unwind().await, so a task panic still delivers the -1 callback 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 panicking extern "C" callback aborts at its own boundary before any caller-side guard can run, so the dispatcher's catch_unwind is only best-effort protection for extern "C-unwind" Rust hosts — never part of the contract.
  • Honest docs (lance.h): double panics, panics in Drop during unwind, stack overflow, and allocation failure still abort; close/free (including Arrow stream release) 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 under panic = "unwind"; the guarded export drives the full Arrow C stream error contract end to end through raw get_next pointers — including when get_next is called from a thread driving a Tokio runtime (where Handle::block_on panics before any poll) — and a panicking destructor reached through the raw release pointer is contained. Both child-process regressions assert a clean exit AND that the panic really fired (via the panic hook's stderr).
  • Rust unit tests covering: guard-arm shapes, poison behavior on all scanner entry-point shapes, with_mut rollback 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 a scan_async setup panic poisons the handle and dispatches exactly one LANCE_ERR_PANIC completion.
  • Full suite: 296 passed / 0 failed; cargo clippy --all-targets -- -D warnings clean; cargo fmt clean; C/C++ compile-and-run tests pass (GCC 13.3.0).

Deliberate minor bugfixes folded in

  • lance_dataset_version now clears the thread-local error on success, like every other guarded fn.
  • lance_dataset_write wraps the shared inner directly instead of bare-forwarding to lance_dataset_write_with_params (an outer guard would have cleared the inner guard's TLS error).

Out of scope / follow-ups

  • Index segment builders (src/index_segment.rs) live on the unmerged distributed-index branch; they need the same poison treatment once that lands.
  • Upstreaming GuardedReader into lance-io so lance-java benefits too.
  • Optional chained panic hook for backtraces (the default hook already prints to stderr).

u70b3 added 7 commits August 20, 2026 14:31
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).
lance-gatekeeper[bot]

This comment was marked as outdated.

@lance-gatekeeper lance-gatekeeper Bot added the K-changes Latest Gatekeeper recommendation requests changes. label Aug 20, 2026
@u70b3
u70b3 marked this pull request as draft August 20, 2026 09:14
@lance-gatekeeper lance-gatekeeper Bot removed the K-changes Latest Gatekeeper recommendation requests changes. label Aug 20, 2026
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
u70b3 marked this pull request as ready for review August 21, 2026 12:08
lance-gatekeeper[bot]

This comment was marked as outdated.

@lance-gatekeeper lance-gatekeeper Bot added the K-approved Latest Gatekeeper recommendation permits acceptance. label Aug 21, 2026
@lance-gatekeeper lance-gatekeeper Bot removed the K-approved Latest Gatekeeper recommendation permits acceptance. label Aug 21, 2026

@lance-gatekeeper lance-gatekeeper Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Gate recommendation: approve.

The follow-up only fixes the private helper reference in module documentation, with no runtime change. The previously verified panic guards and callback/error contracts remain intact.

@lance-gatekeeper lance-gatekeeper Bot added the K-approved Latest Gatekeeper recommendation permits acceptance. label Aug 21, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

K-approved Latest Gatekeeper recommendation permits acceptance.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant