This is an automated email from the ASF dual-hosted git repository.

Jefffrey pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/arrow-rs.git


The following commit(s) were added to refs/heads/main by this push:
     new bab409b82d fix(ffi): report the producer's C stream error message, 
don't unwrap it (#10844)
bab409b82d is described below

commit bab409b82d05651ffb5d94eb7b59005048a16643
Author: Fredrik Fornwall <[email protected]>
AuthorDate: Wed Aug 26 14:14:15 2026 +0200

    fix(ffi): report the producer's C stream error message, don't unwrap it 
(#10844)
    
    # Which issue does this PR close?
    
    No issue filed (this is a minor fix).
    
    # Rationale for this change
    
    `get_stream_schema` discarded the producer's message entirely, reporting
    only the numeric return code, so a failed import left the consumer with
    nothing but an errno.
    
    `Iterator::next` did the opposite, unwrapping the producer's message
    without checking there was one. The C stream interface lets
    `get_last_error` return `NULL` when no detailed description is
    available, so a conforming producer that reported a failure without a
    message made the consumer panic across an FFI boundary instead of
    returning an error.
    
    # What changes are included in this PR?
    
    `get_stream_schema` and `Iterator::next` now includes the producer's
    error message when there is one.
    
    # Are these changes tested?
    
    Yes. `test_error_import` now asserts the full message that a failing
    Rust producer surfaces through a round trip. New tests drive a
    hand-written producer through both the schema and the `next` path,
    covering: a producer message, `get_last_error` returning NULL, and a
    NULL `get_last_error` callback. The NULL-message case panicked before
    this change.
    
    # Are there any user-facing changes?
    
    No API changes; error messages change:
    
    * A failed schema import previously read `Cannot get schema from input
    stream. Error code: 5`, and now appends `. Producer error: <message>`
    when the producer supplies one.
    * A failed `next()` previously surfaced the producer's message alone
    (e.g. `Memory error: out of memory`), and now reads `Cannot get next
    batch from input stream. Error code: 12. Producer error: Memory error:
    out of memory`.
    
    ---
    
    AI usage: Created with claude code and opus 5. I have reviewed the code
    and iterated on it.
    
    ---------
    
    Signed-off-by: Fredrik Fornwall <[email protected]>
---
 arrow-array/src/ffi_stream.rs | 177 +++++++++++++++++++++++++++++++++++++-----
 1 file changed, 156 insertions(+), 21 deletions(-)

diff --git a/arrow-array/src/ffi_stream.rs b/arrow-array/src/ffi_stream.rs
index 87da0d30c0..58e23888f1 100644
--- a/arrow-array/src/ffi_stream.rs
+++ b/arrow-array/src/ffi_stream.rs
@@ -337,6 +337,32 @@ pub struct ArrowArrayStreamReader {
     schema: SchemaRef,
 }
 
+/// Returns the producer's message for the last failed call on a 
`FFI_ArrowArrayStream`.
+///
+/// Returns `None` when the producer supplies no message, either because it 
installs no
+/// `get_last_error` callback or because that callback returns NULL: the C 
Stream Interface
+/// lets `get_last_error` return NULL when no detailed description is 
available.
+///
+/// # Safety
+///
+/// `stream_ptr` must point to a valid, not yet released 
[`FFI_ArrowArrayStream`], and the last
+/// operation on it must have returned an error: the C Stream Interface 
forbids calling
+/// `get_last_error` in any other case.
+unsafe fn producer_error(stream_ptr: *mut FFI_ArrowArrayStream) -> 
Option<String> {
+    let get_last_error = unsafe { (*stream_ptr).get_last_error }?;
+
+    let error_str = unsafe { get_last_error(stream_ptr) };
+    if error_str.is_null() {
+        return None;
+    }
+
+    Some(
+        unsafe { CStr::from_ptr(error_str) }
+            .to_string_lossy()
+            .into_owned(),
+    )
+}
+
 /// Gets schema from a raw pointer of `FFI_ArrowArrayStream`. This is used 
when constructing
 /// `ArrowArrayStreamReader` to cache schema.
 fn get_stream_schema(stream_ptr: *mut FFI_ArrowArrayStream) -> 
Result<SchemaRef> {
@@ -348,9 +374,14 @@ fn get_stream_schema(stream_ptr: *mut 
FFI_ArrowArrayStream) -> Result<SchemaRef>
         let schema = Schema::try_from(&schema)?;
         Ok(Arc::new(schema))
     } else {
-        Err(ArrowError::CDataInterface(format!(
-            "Cannot get schema from input stream. Error code: {ret_code:?}"
-        )))
+        let message = format!("Cannot get schema from input stream. Error 
code: {ret_code}");
+        // SAFETY: `stream_ptr` is valid and unreleased, and the `get_schema` 
call above
+        // returned a non-zero code.
+        let message = match unsafe { producer_error(stream_ptr) } {
+            Some(producer_message) => format!("{message}. Producer error: 
{producer_message}"),
+            None => message,
+        };
+        Err(ArrowError::CDataInterface(message))
     }
 }
 
@@ -382,19 +413,6 @@ impl ArrowArrayStreamReader {
     pub unsafe fn from_raw(raw_stream: *mut FFI_ArrowArrayStream) -> 
Result<Self> {
         Self::try_new(unsafe { FFI_ArrowArrayStream::from_raw(raw_stream) })
     }
-
-    /// Get the last error from `ArrowArrayStreamReader`
-    fn get_stream_last_error(&mut self) -> Option<String> {
-        let get_last_error = self.stream.get_last_error?;
-
-        let error_str = unsafe { get_last_error(&mut self.stream) };
-        if error_str.is_null() {
-            return None;
-        }
-
-        let error_str = unsafe { CStr::from_ptr(error_str) };
-        Some(error_str.to_string_lossy().to_string())
-    }
 }
 
 impl Iterator for ArrowArrayStreamReader {
@@ -423,9 +441,15 @@ impl Iterator for ArrowArrayStreamReader {
                 )
             }))
         } else {
-            let last_error = self.get_stream_last_error();
-            let err = ArrowError::CDataInterface(last_error.unwrap());
-            Some(Err(err))
+            let message =
+                format!("Cannot get next batch from input stream. Error code: 
{ret_code}");
+            // SAFETY: `self.stream` is valid and unreleased by construction, 
and the
+            // `get_next` call above returned a non-zero code.
+            let message = match unsafe { producer_error(&mut self.stream) } {
+                Some(producer_message) => format!("{message}. Producer error: 
{producer_message}"),
+                None => message,
+            };
+            Some(Err(ArrowError::CDataInterface(message)))
         }
     }
 }
@@ -582,7 +606,8 @@ mod tests {
     fn test_error_import() -> Result<()> {
         let schema = Arc::new(Schema::new(vec![Field::new("a", 
DataType::Int32, true)]));
 
-        let iter = 
Box::new(vec![Err(ArrowError::MemoryError(String::new()))].into_iter());
+        let iter =
+            Box::new(vec![Err(ArrowError::MemoryError("out of 
memory".to_string()))].into_iter());
 
         let reader = Box::new(TestRecordBatchReader::new(schema.clone(), 
iter));
 
@@ -600,11 +625,121 @@ mod tests {
 
         // The results should outlive the lifetime of the stream itself.
         assert_eq!(produced_batches.len(), 1);
-        assert!(produced_batches[0].is_err());
+        assert_eq!(
+            produced_batches[0].as_ref().unwrap_err().to_string(),
+            format!(
+                "C Data interface error: Cannot get next batch from input 
stream. \
+                 Error code: {ENOMEM}. Producer error: Memory error: out of 
memory"
+            )
+        );
 
         Ok(())
     }
 
+    unsafe extern "C" fn failing_get_schema(
+        _stream: *mut FFI_ArrowArrayStream,
+        _out: *mut FFI_ArrowSchema,
+    ) -> c_int {
+        EIO
+    }
+
+    unsafe extern "C" fn working_get_schema(
+        _stream: *mut FFI_ArrowArrayStream,
+        out: *mut FFI_ArrowSchema,
+    ) -> c_int {
+        let schema = Schema::new(vec![Field::new("a", DataType::Int32, true)]);
+        unsafe { std::ptr::write(out, 
FFI_ArrowSchema::try_from(&schema).unwrap()) };
+        0
+    }
+
+    unsafe extern "C" fn failing_get_next(
+        _stream: *mut FFI_ArrowArrayStream,
+        _out: *mut FFI_ArrowArray,
+    ) -> c_int {
+        EIO
+    }
+
+    unsafe extern "C" fn producer_last_error(_stream: *mut 
FFI_ArrowArrayStream) -> *const c_char {
+        c"the producer failed".as_ptr()
+    }
+
+    unsafe extern "C" fn null_last_error(_stream: *mut FFI_ArrowArrayStream) 
-> *const c_char {
+        std::ptr::null()
+    }
+
+    unsafe extern "C" fn mark_released(stream: *mut FFI_ArrowArrayStream) {
+        unsafe { (*stream).release = None };
+    }
+
+    fn failing_stream(
+        get_last_error: Option<unsafe extern "C" fn(*mut FFI_ArrowArrayStream) 
-> *const c_char>,
+    ) -> FFI_ArrowArrayStream {
+        let mut stream = FFI_ArrowArrayStream::empty();
+        stream.get_schema = Some(failing_get_schema);
+        stream.get_next = Some(failing_get_next);
+        stream.get_last_error = get_last_error;
+        stream.release = Some(mark_released);
+        stream
+    }
+
+    #[test]
+    fn test_import_schema_error_reports_producer_message() {
+        let err =
+            
ArrowArrayStreamReader::try_new(failing_stream(Some(producer_last_error))).unwrap_err();
+        assert_eq!(
+            err.to_string(),
+            format!(
+                "C Data interface error: Cannot get schema from input stream. \
+                 Error code: {EIO}. Producer error: the producer failed"
+            )
+        );
+    }
+
+    #[test]
+    fn test_import_schema_error_without_producer_message() {
+        // A producer need not supply a message: `get_last_error` may return 
NULL when no
+        // detailed description is available.
+        let err =
+            
ArrowArrayStreamReader::try_new(failing_stream(Some(null_last_error))).unwrap_err();
+        assert_eq!(
+            err.to_string(),
+            format!(
+                "C Data interface error: Cannot get schema from input stream. 
Error code: {EIO}"
+            )
+        );
+    }
+
+    #[test]
+    fn test_import_schema_error_without_error_callback() {
+        let err = 
ArrowArrayStreamReader::try_new(failing_stream(None)).unwrap_err();
+        assert_eq!(
+            err.to_string(),
+            format!(
+                "C Data interface error: Cannot get schema from input stream. 
Error code: {EIO}"
+            )
+        );
+    }
+
+    #[test]
+    fn test_import_next_error_without_producer_message() {
+        // Previously panicked: the message was unwrapped without checking 
that the producer
+        // supplied one.
+        let mut stream = failing_stream(Some(null_last_error));
+        stream.get_schema = Some(working_get_schema);
+
+        let err = ArrowArrayStreamReader::try_new(stream)
+            .unwrap()
+            .next()
+            .unwrap()
+            .unwrap_err();
+        assert_eq!(
+            err.to_string(),
+            format!(
+                "C Data interface error: Cannot get next batch from input 
stream. Error code: {EIO}"
+            )
+        );
+    }
+
     // A consumer wraps the release callback with its own, then chains back to
     // the original on drop. This is the same wrap-release pattern the
     // release/private_data accessors exist for (#9771).

Reply via email to