# RustFuture poll codes UNIFFI_RUST_FUTURE_POLL_READY = 0 UNIFFI_RUST_FUTURE_POLL_WAKE = 1 # Handle map for storing write-end IO objects used by the continuation callbacks. UNIFFI_ASYNC_HANDLE_MAP = UniffiHandleMap.new # Continuation callback for async functions. # Called by Rust when the future is ready to make progress. # Writes the poll code to the pipe so the waiting thread/fiber can continue. # # Exceptions must never escape this Proc. # Rust invokes it directly via FFI, so any unhandled Ruby exception would be caught # by Ruby-FFI, which swallows it (prints a warning) and returns a garbage value to Rust. # The poll code Rust receives would be invalid, causing the Ruby polling loop to # never see POLL_READY and block forever. UNIFFI_CONTINUATION_CALLBACK = Proc.new do |data, poll_code| begin wr = UNIFFI_ASYNC_HANDLE_MAP.get data # This can only be blocking if pipe if full. Rust will never try to call continuation # callback more than once (so will not fill it up), hence this call should never block. wr.putc poll_code rescue Exception # Swallow exception. A leak or a hang is better than a hard VM segfault. end end # Poll a Rust future to completion. # # This works both with and without a Fiber::Scheduler: # - Without scheduler: wait_readable blocks with a timeout, releasing the GVL # so the callback can fire from foreign threads (works around MRI limitation # where rb_thread_call_with_gvl cannot wake up threads in indefinite sleep). # - With scheduler: wait_readable hooks into io_wait, yielding the fiber. # # cancel_fn is called in the ensure block when exception interrupts an in-flight poll. # This guarantees Rust fires the continuation callback so the handle-map entry is released # and the pipe is drained before we free the future. def self.uniffi_rust_call_async(rust_future, poll_fn, cancel_fn, complete_fn, free_fn, lift_func, error_ffi_converter) rd = wr = nil handle = nil poll_in_flight = false begin rd, wr = IO.pipe wr.sync = true # avoid buffering and delayed read for rd.wait_readable handle = UNIFFI_ASYNC_HANDLE_MAP.insert wr loop do poll_in_flight = true UniFFILib.public_send(poll_fn, rust_future, UNIFFI_CONTINUATION_CALLBACK, handle) # Blocks until the continuation callback writes to the pipe. # Releases the GVL so the callback can fire from foreign threads. # With a Fiber::Scheduler, this hooks into io_wait for non-blocking concurrency. rd.wait_readable poll_code = rd.getbyte poll_in_flight = false break if poll_code == UNIFFI_RUST_FUTURE_POLL_READY end result = if error_ffi_converter.nil? ::{{ ci.namespace()|class_name_rb }}.rust_call(complete_fn, rust_future) else ::{{ ci.namespace()|class_name_rb }}.rust_call_with_error(error_ffi_converter, complete_fn, rust_future) end lift_func.call(result) ensure # Defer any further Thread#raise / Timeout during cleanup so all steps execute # automatically. Without this, a second raise during wait_readable(0.5) would skip # handle-map removal, pipe close, and free_fn - leaking FDs and Rust memory. Thread.handle_interrupt(Exception => :never) do if poll_in_flight # An exception interrupted an in-flight poll. Cancel and drain the byte # the continuation callback will write so we don't leak the pipe. UniFFILib.public_send(cancel_fn, rust_future) # rd.wait_readable may time out and return nil if the poll was never actually sent # (raise landed between poll_in_flight=true and the FFI call). if rd.wait_readable(0.5) rd.getbyte end end # Remove handle first so any late-firing callback's `get` raises (swallowed by rescue). UNIFFI_ASYNC_HANDLE_MAP.remove(handle) rescue nil if handle rd&.close rescue nil wr&.close rescue nil UniFFILib.public_send(free_fn, rust_future) end end end {%- if ci.has_async_callback_interface_definition() %} # Exception raised when a foreign future is canceled. class UniffiInternalCancelled < RuntimeError; end # User callback that raises it will be considered a Rust-side cancellation. private_constant :UniffiInternalCancelled # Handle map for storing Threads executing foreign async callbacks. UNIFFI_FOREIGN_FUTURE_HANDLE_MAP = UniffiHandleMap.new # One-shot claim flag: the first caller to `claim!` wins; all subsequent callers # are no-ops. Used to enforce the at-most-once contract on uniffi_future_callback. class UniffiOnceFlag def initialize @mutex = Mutex.new @claimed = false end # Returns true if this caller won the race (first to claim), false otherwise. def claim! @mutex.synchronize do first = !@claimed @claimed = true first end end end # Called by Rust when the foreign future is dropped (i.e. canceled or completed successfully). # Raises UniffiInternalCancelled in the worker thread so make_call can exit early, # but only if the thread hasn't already completed and claimed the once flag. # Stored as a constant to prevent GC from collecting the Proc while Rust holds the pointer. UNIFFI_FOREIGN_FUTURE_DROPPED_CALLBACK = Proc.new do |handle| thread, once = UNIFFI_FOREIGN_FUTURE_HANDLE_MAP.remove handle thread.raise(UniffiInternalCancelled, 'Future was canceled') if once.claim! && thread&.alive? end # Execute a foreign async callback method in a background thread. # Enforces the at-most-once guarantee on handle_success / handle_error: whichever # fires first (normal completion or Rust-side drop) suppresses the other. def self.uniffi_trait_interface_call_async(make_call, uniffi_out_dropped_callback, handle_success, handle_error, error_type = nil, lower_error = nil) once = UniffiOnceFlag.new thread = Thread.new do begin # Phase 1: run the user's async method. # UniffiInternalCancelled exits silently. Other exceptions are forwarded as errors. # handle_success is intentionally called outside this rescue so exceptions from it # cannot re-enter handle_error (which would be a double-call on the Rust sender). begin result = make_call.call rescue UniffiInternalCancelled next rescue Exception => e # We have to catch all errors to prevent Rust future from hanging forever. next unless once.claim! if !error_type.nil? && ::{{ ci.namespace()|class_name_rb }}.uniffi_is_error_type?(e, error_type) handle_error.call(UNIFFI_CALLBACK_ERROR, lower_error.call(e)) else handle_error.call(UNIFFI_CALLBACK_UNEXPECTED_ERROR, {{ "e.inspect"|lower_rb(&Type::String, config) }}) end next end # Phase 2: deliver the result to Rust. Skipped if dropped_callback already fired. handle_success.call(result) if once.claim! rescue UniffiInternalCancelled # Thread#raise landed between phases or during Phase 2 - silently exit. # Rust already dropped the future (that's why dropped_callback fired), so no response needed. rescue Exception => e # handle_success/handle_error/lower_error raised - send a generic error so Rust doesn't hang. # once was already claimed, so only attempt this if we can still claim (e.g. lowering failed # before handle_error was called due to short-circuit evaluation). begin handle_error.call(UNIFFI_CALLBACK_UNEXPECTED_ERROR, {{ "e.inspect"|lower_rb(&Type::String, config) }}) rescue Exception # If even this fails, Rust will hang. Nothing more we can do. end end end # Note: the thread may have already completed by this point, but that's safe. # Rust cannot invoke dropped_callback until this function returns. # possesses the ForeignFuture struct we're populating here. handle = UNIFFI_FOREIGN_FUTURE_HANDLE_MAP.insert([thread, once]) uniffi_out_dropped_callback[:handle] = handle uniffi_out_dropped_callback[:free] = UNIFFI_FOREIGN_FUTURE_DROPPED_CALLBACK end {%- endif %}