Skip to content

fix(ffi): make unified set_callback one-shot while streaming is live#864

Merged
userFRM merged 1 commit into
mainfrom
fix/unified-set-callback-gate
Jun 17, 2026
Merged

fix(ffi): make unified set_callback one-shot while streaming is live#864
userFRM merged 1 commit into
mainfrom
fix/unified-set-callback-gate

Conversation

@userFRM

@userFRM userFRM commented Jun 17, 2026

Copy link
Copy Markdown
Owner

Callback registration on the unified client is one-shot for the duration of a live session and serialised against concurrent self-calls, matching the FPSS path and the documented -1 contract.

Defect

thetadatadx_client_set_callback documented (in its Rust docstring and the C header) that a second call while streaming is already active returns -1 with "streaming already started", but the implementation had no such gate. It unconditionally overwrote the stored (callback, ctx) and re-entered start_streaming. Two consequences, same root:

  • A second call while a session was live re-bound the stored callback while the prior session's consumer could still fire the old ctx, returning 0 with no drain barrier — the documented -1 contract was never enforced.
  • The install was not serialised against concurrent self-calls: the handle's callback mutex was taken to store, released, then start_streaming was called separately, so two racing threads could store different callbacks and the live consumer's ctx could diverge from the stored one.

Fix

Bring the unified entry point to the same discipline the FPSS sibling thetadatadx_streaming_set_callback already uses (gate held under one lock across the install). The unified handle's serialisation point is its callback mutex, so the install now holds handle.callback across the gate, the store, and start_streaming — the same shape as the FPSS path holding dispatcher across reject_if_not_fresh + open_fpss. A live session is detected via is_streaming() before any state mutation and rejected with -1 and "streaming already started", leaving the live registration untouched.

Replacement after thetadatadx_client_stop_streaming / _reconnect is still permitted because the slot is no longer live at that point — the unified path keeps its documented high-level stop-then-re-register flow. The dispatcher invokes the callback captured by copy into the closure, never reading the mutex, so holding the lock across start_streaming cannot deadlock the first delivered event. The documented contract (Rust docstring + C header) already matched; this makes the implementation match the documentation rather than the other way round.

Test

Extended the unified set_callback guard tests to drive the real C ABI entry point: a null handle returns -1 with "unified handle is null", a null callback returns -1 with "callback function pointer is null" (rejected before any handle dereference), and the live-handle gate's "streaming already started" contract string is pinned so the documented C ABI wording cannot drift from the implementation. Mirrors the FPSS null/double-callback guard tests.

Verification

  • cargo check -p thetadatadx-ffi — clean
  • cargo clippy -p thetadatadx-ffi --all-targets -- -D warnings — clean
  • cargo test -p thetadatadx-ffi — all pass (4 new unit tests green)
  • cargo fmt --all -- --check — clean
  • python3 scripts/check_binding_parity.py — clean

🤖 Generated with Claude Code

Callback registration on the unified client is one-shot for the duration of a live session and serialised against concurrent self-calls, matching the FPSS path and the documented -1 contract. The entry point previously overwrote the stored callback and re-entered start_streaming unconditionally, so a second call while a session was live could leave the started session bound to a callback that diverges from the one stored on the handle, and a racing self-call could install one callback while another started a different one.

The install now holds handle.callback across the gate, the store, and start_streaming, the same way the FPSS path holds dispatcher across its gate and open_fpss. A live session is detected before any state mutation and rejected with -1 and "streaming already started", leaving the live registration untouched. Replacement after thetadatadx_client_stop_streaming or _reconnect is still permitted because the slot is no longer live at that point. The dispatcher invokes the callback captured by copy, never reading the mutex, so holding the lock across start_streaming cannot deadlock the first delivered event.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@userFRM userFRM enabled auto-merge (squash) June 17, 2026 16:13
@userFRM userFRM merged commit cea8632 into main Jun 17, 2026
44 checks passed
@userFRM userFRM deleted the fix/unified-set-callback-gate branch June 17, 2026 17:07
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants