Skip to content

fix: correct .NET binding FFI marshalling and free native results#5848

Merged
max-sixty merged 1 commit intomainfrom
fix/dotnet-ffi-defects-25039216867
Apr 28, 2026
Merged

fix: correct .NET binding FFI marshalling and free native results#5848
max-sixty merged 1 commit intomainfrom
fix/dotnet-ffi-defects-25039216867

Conversation

@prql-bot
Copy link
Copy Markdown
Collaborator

Fixes #5847.

The .NET binding had several FFI defects that prevented it from working correctly on 64-bit platforms or returning more than one diagnostic message. This PR addresses defects 1–6 from the issue plus a related null-pointer bug in prqlc-c::result_destroy that surfaced once the binding actually started calling it.

Changes to the .NET binding

# Defect Fix
1 result_destroy never called — every compile leaked the output string and messages array. Result's constructor now decodes everything from the native struct, then calls result_destroy in a finally. NativeResult.Output is IntPtr so the original native pointer is retained for the destroy call; UTF-8 decoding happens in C#.
2 size_t mapped as int (4 bytes) instead of UIntPtr (8 bytes on 64-bit) — corrupted struct layout. MessagesLen, Span.Start/End, and SourceLocation.StartLine/StartCol/EndLine/EndCol now use UIntPtr for marshalling. Public Span / SourceLocation expose the values as ulong.
3 All messages collapsed to the first entry (the marshaller read from the same pointer every iteration). The loop now advances the pointer with IntPtr.Add(result.Messages, i * Marshal.SizeOf<NativeMessage>()).
4 Message struct layout did not match the C struct — optional fields are const char *const * (pointer-to-pointer) and span / location are const Span * / const SourceLocation *. Internal NativeMessage uses IntPtr for every pointer field; a new public Message class exposes nullable string properties and Span? / SourceLocation?. Indirect string fields are dereferenced once before reading the C string.
5 ArgumentException thrown for null options, but XML doc advertised ArgumentNullException. Throws ArgumentNullException.
6 RqToSql doc comments referenced <paramref name="prqlQuery"/> instead of rqJson. Fixed.

Related fix to prqlc-c::result_destroy

On the success path result_into_c_str sets messages: ::std::ptr::null_mut() and messages_len: 0. The original result_destroy then called Vec::from_raw_parts(null, 0, 0), which violates NonNull::new_unchecked's precondition and aborts under modern Rust's debug check (unsafe precondition(s) violated: NonNull::new_unchecked requires that the pointer is non-null). The destructor now skips the messages free and the output free when those pointers are null, so calling result_destroy after a successful compile is safe.

This was previously latent — the existing C/C++ examples called result_destroy after a clean compile too, but built prqlc-c in release mode where the precondition check is elided. The .NET CI builds in debug mode, which is how the bug surfaced.

Tests

  • New: an error-path compile that asserts Span, Location, and Display are all populated, exercising every indirect-pointer field at once.
  • New: ArgumentNullException assertions for Compile and RqToSql when options is null.
  • Existing tests pass without modification.

Note on API breakage

Span, SourceLocation, and Message are exposed publicly and their shapes change (struct → class for Message; field types from int to ulong for the size_t cases; nullable wrappers for optional fields). The package is still at 0.1.0 and is not published to NuGet, so this is acceptable.

Several FFI defects in `prqlc/bindings/dotnet/` prevented the binding from
working correctly on 64-bit platforms or returning more than one diagnostic
(reported in #5847). This change:

- Maps `size_t` as `UIntPtr` instead of `int` for `messages_len`, `Span`,
  and `SourceLocation` so the marshalled struct layout matches the C ABI.
- Iterates the `messages` array by advancing the `IntPtr` (was reading the
  same first entry every iteration).
- Dereferences the indirect `code` / `hint` / `display` pointer-to-pointer
  fields and the `span` / `location` pointers manually instead of asking
  the marshaller to inline them by value.
- Calls `result_destroy` from `Result`'s constructor so native memory is
  not leaked. `NativeResult.Output` is now `IntPtr` so the original native
  pointer is retained for the destroy call; the string is decoded as UTF-8
  before being returned through the public API.
- Tightens the exception type to `ArgumentNullException` for null
  `options` (the XML doc already documented it) and fixes the stale
  `<paramref>` references in `RqToSql`.

`prqlc-c::result_destroy` previously hit
`Vec::from_raw_parts(null, 0, 0)` on the success path, which is undefined
behaviour and panics under modern Rust's debug checks. Guarding the
messages free (and the output free) with a null check makes the
destructor safe to call after a successful compile, which the .NET
binding now does.

Adds tests covering the error path (Span / Location / Display all
populated through indirect pointers) and the corrected
`ArgumentNullException` behaviour.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@max-sixty max-sixty merged commit fd7c5c8 into main Apr 28, 2026
38 checks passed
@max-sixty max-sixty deleted the fix/dotnet-ffi-defects-25039216867 branch April 28, 2026 15:52
@vanillajonathan
Copy link
Copy Markdown
Collaborator

Hey @prql-bot this is great, do you think could upgrade it to .NET 10? If so, do you think you could change DllImport to LibraryImport? And and use { get; init; } instead of { get; set; } for the properties? Or replace the structs with readonly structs? maybe use records? and use nullable reference types and implicit usings in the .csproj file?

@prql-bot
Copy link
Copy Markdown
Collaborator Author

Thanks @vanillajonathan — opened #5850 with the modernization. It bumps the binding to net10.0, switches to LibraryImport with explicit UTF-8 marshalling, makes Span / SourceLocation readonly record struct, makes Message / PrqlCompilerOptions records with { get; init; }, and enables nullable reference types and implicit usings.

The main trade-off is dropping the existing netstandard2.0 target. Since the package is still at 0.1.0 and unpublished (per the README's TODO), this seemed acceptable, but I called it out in the PR description in case the maintainers prefer a smaller scope that keeps netstandard2.0 compat.

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.

dotnet binding: memory leak, size_t/int mismatch, broken Message marshalling, layout bugs

3 participants