Skip to content

fix(ffi): preserve detailed errors across the C ABI (issue #17) - #19

Merged
eric8810 merged 2 commits into
masterfrom
fix/ffi-error-details
Aug 3, 2026
Merged

fix(ffi): preserve detailed errors across the C ABI (issue #17)#19
eric8810 merged 2 commits into
masterfrom
fix/ffi-error-details

Conversation

@eric8810

@eric8810 eric8810 commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

Summary

Fixes #17 — the C ABI layer (aimux-ffi, shared by Go/Java/Kotlin/Swift/Flutter/C) dropped detailed errors in two places.

Problem A — prompt_json / config_json parse errors lost

aimux_generate_text / aimux_stream_text discarded the serde_json::Error from prompt_json (callers only saw "invalid prompt_json"); aimux_provider_new also swallowed config_json parse errors (Err(_) => return 0). Both now report the serde detail exactly like the adjacent opts_json branch already did.

Problem B — all constructors reduced AiMuxError to a bare 0

20 language-model constructors (and every multimodal constructor for null args) dropped the full AiMuxError (e.g. UnknownProvider with the available-provider list, missing env var, bad config). Solution without breaking the ABI (55 symbols untouched, 1 added):

  • thread-local error slot + aimux_last_error(): read-and-clear, returns the existing error JSON envelope {"error","error_type","status_code"}, caller frees with aimux_free_string
  • every u64 constructor clears the slot on entry, records the envelope on failure, stays empty on success; null/invalid-UTF-8 args record InvalidArgument

Bindings

  • Go: takeLastError() helper; runtime.LockOSThread() around constructor+read in newModel, wrapHandle, ProviderWithBase, newMultimodalHandle (constructors are pure config building, so pinning is cheap)
  • Java: JNA aimux_last_error() declared as Pointer (released via aimux_free_string); constructorFailure() extracts the error field for all 21 model factories
  • C: example.c prints the detailed error on constructor failure

Kotlin/Swift/Flutter call the same symbols and can adopt aimux_last_error() the same way (thread-affinity note in the header docs); not changed in this PR.

Verification

  • cargo test -p aimux-ffi: 15 passed (11 new regression tests in error_detail_test.rs + 4 existing smoke tests)
  • cargo check --workspace: clean
  • New tests cover: serde detail in prompt errors, UnknownProvider/Json/InvalidArgument envelopes, clear-on-success, last-write-wins, read-once, TLS isolation
  • ⚠️ Go and Java bindings could not be compiled on this Windows machine (no gcc for cgo / no JDK) — they are static-only changes following existing patterns; CI will verify

Notes

  • ABI: only aimux_last_error() is added; 0 remains the documented failure sentinel
  • aimux_last_error() semantics (destructive read, same OS thread as the failed constructor, caller-owned pointer) are documented in aimux-ffi.h

Problem A: aimux_generate_text / aimux_stream_text dropped the
serde_json::Error from prompt_json parsing (only "invalid prompt_json"
remained); aimux_provider_new also swallowed config_json parse errors.
Report them like the adjacent opts_json branch already did.

Problem B: all 20 language-model constructors (and the multimodal ones
for null arguments) reduced every AiMuxError to a bare 0 handle. Add a
thread-local error slot + aimux_last_error() returning the error JSON
envelope (read-and-clear, caller frees with aimux_free_string). Every
u64 constructor clears the slot on entry, records the envelope on
failure, and stays empty on success.

Bindings:
- Go: takeLastError() + LockOSThread around constructor+read in
  newModel / wrapHandle / ProviderWithBase / newMultimodalHandle
- Java: JNA aimux_last_error() (Pointer + aimux_free_string) and
  constructorFailure() used by all 21 model factories
- C: example.c prints the detailed error on constructor failure

Tests: error_detail_test.rs covers serde detail, UnknownProvider/Json/
InvalidArgument envelopes, clear-on-success, last-write-wins, read-once
and TLS isolation. cargo test -p aimux-ffi: 15 passed.
cargo fmt: wrap record_error_msg calls at 100 cols. Java: use fully-qualified java.util.regex.Matcher (no import needed).
@eric8810
eric8810 merged commit 65d8349 into master Aug 3, 2026
18 checks passed
cunninghamcard-bit added a commit to cunninghamcard-bit/aimux that referenced this pull request Aug 3, 2026
…e envelope design

Port the applicable cases from PR arcships#19's error_detail_test.rs (serde detail
in prompt errors for generate/stream, UnknownProvider / Json /
InvalidArgument envelopes, azure required-arg) and align the envelope
contents with that PR: null / invalid-UTF-8 arguments now report
error_type "InvalidArgument" (was "Other") and config_json parse
failures report "Json", so the two designs emit identical error
envelopes.

The last_error TLS-semantics tests (clear-on-success, last-write-wins,
read-once, thread-local isolation) have no counterpart here — each call
returns its own self-contained result. Replaced with envelope-semantics
tests: success shape, per-call error independence, and an 8-thread
concurrent-constructor check that needs no thread pinning.

cargo test -p aimux-ffi --release: 14 passed (10 new + 4 smoke); Go
binding go test ./... passes against the rebuilt staticlib.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
cunninghamcard-bit added a commit to cunninghamcard-bit/aimux that referenced this pull request Aug 3, 2026
…sue arcships#17)

Constructors returned a bare u64 with 0 on any failure, discarding the
AiMuxError already computed underneath (unknown provider, bad config,
missing env var, invalid model id). Instead of adding a side-channel
retrieval symbol, widen the return channel itself: every aimux_*_new /
aimux_provider_new / aimux_provider_from_env now returns the JSON
envelope the rest of the ABI already uses:

    {"handle":<u64>}                                    on success
    {"error":"...","error_type":"...","status_code":..} on failure

Success and failure arrive in the same return value of the same call, so
there is nothing to correlate across calls and no thread-affinity
requirement for any binding runtime.

Also fixes Problem A of arcships#17: aimux_generate_text / aimux_stream_text
preserve the serde_json detail for invalid prompt_json (matching the
adjacent opts_json branch), and aimux_provider_new preserves it for
invalid config_json. Error types align with PR arcships#19's envelopes:
InvalidArgument for null/invalid-UTF-8 args, Json for config parse
failures.

Tests: error_detail_test.rs ports the applicable regression cases from
PR arcships#19 (serde detail for generate/stream, UnknownProvider / Json /
InvalidArgument envelopes); the last_error TLS-semantics cases have no
counterpart here and are replaced with envelope-semantics tests
(per-call error independence, 8-thread concurrent constructors, no
pinning). native_constructors_test.rs rewritten to parse the envelope.

cargo test -p aimux-ffi --release: 14 passed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
cunninghamcard-bit added a commit to cunninghamcard-bit/aimux that referenced this pull request Aug 3, 2026
…imux_last_error (issue arcships#17)

Follow-up simplification of arcships#19: instead of a bare u64 handle plus the
aimux_last_error() thread-local side channel, every constructor returns
the JSON envelope the rest of the ABI already uses:

    {"handle":<u64>}                                    on success
    {"error":"...","error_type":"...","status_code":..} on failure

Success and failure arrive in the same return value of the same call, so
there is no second call to correlate — the entire last_error mechanism
(LAST_ERROR TLS slot, begin_constructor, record_error/record_error_msg,
the aimux_last_error symbol and its thread-affinity contract) is removed.
No binding runtime needs thread pinning.

Error envelopes are unchanged from arcships#19 (InvalidArgument for null /
invalid-UTF-8 args, Json for config parse failures, UnknownProvider with
the available-provider list), and Problem A's serde detail for
prompt_json/config_json is preserved.

error_detail_test.rs: the envelope-relevant cases from arcships#19 are kept and
read the error from the constructor result; the TLS-semantics cases
(clear-on-success, last-write-wins, read-once, thread-local isolation)
have no counterpart — replaced with envelope-semantics tests (per-call
error independence, 8-thread concurrent constructors, no pinning).

cargo test -p aimux-ffi --release: 14 passed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
cunninghamcard-bit added a commit to cunninghamcard-bit/aimux that referenced this pull request Aug 3, 2026
parseHandleJSON reads the envelope, frees the C string, and surfaces the
detailed error; a degenerate envelope with neither error nor handle is
rejected. The takeLastError/callConstructor dispatch machinery and
runtime.LockOSThread pinning from arcships#19 are removed — constructors are
plain direct C calls, since the result is self-contained.

Also removes stale StreamTextContext doc references (no such API); the
per-call timeout (RFC-0016 H3) is the only interruption mechanism.

go vet + go test ./... pass with a real cgo link; E2E against a live
OpenAI-compatible endpoint verifies unknown-provider detail, prompt_json
serde detail, real generate, and real streaming.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
cunninghamcard-bit added a commit to cunninghamcard-bit/aimux that referenced this pull request Aug 3, 2026
All JNA constructor declarations return Pointer; AimuxResult.extractHandle
frees exactly once (try/finally), null-checks, and throws with the
detailed engine error. The aimux_last_error declaration and
constructorFailure helper from arcships#19 are removed. Static-only change; CI
to verify.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
cunninghamcard-bit added a commit to cunninghamcard-bit/aimux that referenced this pull request Aug 3, 2026
example.c / example.cpp parse the envelope and print the detailed engine
error on constructor failure (replacing arcships#19's aimux_last_error read in
example.c); example.cpp includes <stdexcept> for its throw sites. Both
compile against the new header.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
cunninghamcard-bit added a commit to cunninghamcard-bit/aimux that referenced this pull request Aug 3, 2026
…imux_last_error (issue arcships#17)

Follow-up simplification of arcships#19: instead of a bare u64 handle plus the
aimux_last_error() thread-local side channel, every constructor returns
the JSON envelope the rest of the ABI already uses:

    {"handle":<u64>}                                    on success
    {"error":"...","error_type":"...","status_code":..} on failure

Success and failure arrive in the same return value of the same call, so
there is no second call to correlate — the entire last_error mechanism
(LAST_ERROR TLS slot, begin_constructor, record_error/record_error_msg,
the aimux_last_error symbol and its thread-affinity contract) is removed.
No binding runtime needs thread pinning.

Error envelopes are unchanged from arcships#19 (InvalidArgument for null /
invalid-UTF-8 args, Json for config parse failures, UnknownProvider with
the available-provider list), and Problem A's serde detail for
prompt_json/config_json is preserved.

error_detail_test.rs: the envelope-relevant cases from arcships#19 are kept and
read the error from the constructor result; the TLS-semantics cases
(clear-on-success, last-write-wins, read-once, thread-local isolation)
have no counterpart — replaced with envelope-semantics tests (per-call
error independence, 8-thread concurrent constructors, no pinning).

cargo test -p aimux-ffi --release: 14 passed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
cunninghamcard-bit added a commit to cunninghamcard-bit/aimux that referenced this pull request Aug 3, 2026
parseHandleJSON reads the envelope, frees the C string, and surfaces the
detailed error; a degenerate envelope with neither error nor handle is
rejected. The takeLastError/callConstructor dispatch machinery and
runtime.LockOSThread pinning from arcships#19 are removed — constructors are
plain direct C calls, since the result is self-contained.

Also removes stale StreamTextContext doc references (no such API); the
per-call timeout (RFC-0016 H3) is the only interruption mechanism.

go vet + go test ./... pass with a real cgo link; E2E against a live
OpenAI-compatible endpoint verifies unknown-provider detail, prompt_json
serde detail, real generate, and real streaming.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
cunninghamcard-bit added a commit to cunninghamcard-bit/aimux that referenced this pull request Aug 3, 2026
All JNA constructor declarations return Pointer; AimuxResult.extractHandle
frees exactly once (try/finally), null-checks, and throws with the
detailed engine error. The aimux_last_error declaration and
constructorFailure helper from arcships#19 are removed. Static-only change; CI
to verify.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
cunninghamcard-bit added a commit to cunninghamcard-bit/aimux that referenced this pull request Aug 3, 2026
example.c / example.cpp parse the envelope and print the detailed engine
error on constructor failure (replacing arcships#19's aimux_last_error read in
example.c); example.cpp includes <stdexcept> for its throw sites. Both
compile against the new header.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
eric8810 pushed a commit that referenced this pull request Aug 3, 2026
…imux_last_error (issue #17) (#21)

* fix(ffi): constructors return JSON handle/error envelope, replacing aimux_last_error (issue #17)

Follow-up simplification of #19: instead of a bare u64 handle plus the
aimux_last_error() thread-local side channel, every constructor returns
the JSON envelope the rest of the ABI already uses:

    {"handle":<u64>}                                    on success
    {"error":"...","error_type":"...","status_code":..} on failure

Success and failure arrive in the same return value of the same call, so
there is no second call to correlate — the entire last_error mechanism
(LAST_ERROR TLS slot, begin_constructor, record_error/record_error_msg,
the aimux_last_error symbol and its thread-affinity contract) is removed.
No binding runtime needs thread pinning.

Error envelopes are unchanged from #19 (InvalidArgument for null /
invalid-UTF-8 args, Json for config parse failures, UnknownProvider with
the available-provider list), and Problem A's serde detail for
prompt_json/config_json is preserved.

error_detail_test.rs: the envelope-relevant cases from #19 are kept and
read the error from the constructor result; the TLS-semantics cases
(clear-on-success, last-write-wins, read-once, thread-local isolation)
have no counterpart — replaced with envelope-semantics tests (per-call
error independence, 8-thread concurrent constructors, no pinning).

cargo test -p aimux-ffi --release: 14 passed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(go): consume JSON handle/error envelope from constructors

parseHandleJSON reads the envelope, frees the C string, and surfaces the
detailed error; a degenerate envelope with neither error nor handle is
rejected. The takeLastError/callConstructor dispatch machinery and
runtime.LockOSThread pinning from #19 are removed — constructors are
plain direct C calls, since the result is self-contained.

Also removes stale StreamTextContext doc references (no such API); the
per-call timeout (RFC-0016 H3) is the only interruption mechanism.

go vet + go test ./... pass with a real cgo link; E2E against a live
OpenAI-compatible endpoint verifies unknown-provider detail, prompt_json
serde detail, real generate, and real streaming.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(java): consume JSON handle/error envelope from constructors

All JNA constructor declarations return Pointer; AimuxResult.extractHandle
frees exactly once (try/finally), null-checks, and throws with the
detailed engine error. The aimux_last_error declaration and
constructorFailure helper from #19 are removed. Static-only change; CI
to verify.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(kotlin): consume JSON handle/error envelope from constructors

Same shape as the Java binding: Pointer? externs, extractHandle with
free-in-finally, detailed errors surfaced. Static-only change; CI to
verify.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(swift): consume JSON handle/error envelope from constructors

extractHandle copies the string, frees once, then throws AimuxError with
the detailed message on the error envelope. swift build passes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(flutter): consume JSON handle/error envelope from constructors

Constructor typedefs return Pointer<Utf8>; _extractHandle frees once and
throws StateError with the detailed message. Static-only change; CI to
verify (no dart SDK in the authoring environment).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(c): consume JSON handle/error envelope from constructors

example.c / example.cpp parse the envelope and print the detailed engine
error on constructor failure (replacing #19's aimux_last_error read in
example.c); example.cpp includes <stdexcept> for its throw sites. Both
compile against the new header.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
eric8810 added a commit that referenced this pull request Aug 3, 2026
Resolve conflicts with #19/#21 (FFI constructor JSON-envelope rework): adopt master's reworked FFI/bindings and re-apply init_logging entries (aimux-ffi C ABI + Go InitLogging). Auto-merged bindings retain both sides.
eric8810 added a commit that referenced this pull request Aug 3, 2026
* fix(ffi): preserve detailed errors across the C ABI (issue #17)

Problem A: aimux_generate_text / aimux_stream_text dropped the
serde_json::Error from prompt_json parsing (only "invalid prompt_json"
remained); aimux_provider_new also swallowed config_json parse errors.
Report them like the adjacent opts_json branch already did.

Problem B: all 20 language-model constructors (and the multimodal ones
for null arguments) reduced every AiMuxError to a bare 0 handle. Add a
thread-local error slot + aimux_last_error() returning the error JSON
envelope (read-and-clear, caller frees with aimux_free_string). Every
u64 constructor clears the slot on entry, records the envelope on
failure, and stays empty on success.

Bindings:
- Go: takeLastError() + LockOSThread around constructor+read in
  newModel / wrapHandle / ProviderWithBase / newMultimodalHandle
- Java: JNA aimux_last_error() (Pointer + aimux_free_string) and
  constructorFailure() used by all 21 model factories
- C: example.c prints the detailed error on constructor failure

Tests: error_detail_test.rs covers serde detail, UnknownProvider/Json/
InvalidArgument envelopes, clear-on-success, last-write-wins, read-once
and TLS isolation. cargo test -p aimux-ffi: 15 passed.

* style(ffi): cargo fmt + fix Java Matcher reference (CI #19)

cargo fmt: wrap record_error_msg calls at 100 cols. Java: use fully-qualified java.util.regex.Matcher (no import needed).

* feat(logging): unified tracing observability (RFC-0014)

- logging.rs: init_logging (Once-guarded, never overrides host subscriber) + lazy env auto-init (AIMUX_LOG / AIMUX_LOG_LEVEL) + body redaction (AIMUX_LOG_BODY=1, 4KB truncation, auth-field masking)
- http.rs: http_request span (method/host/attempt) + request/response debug events (URL without query, header count only) + retry warn / failed error events + ObservedByteStream (ttfb/chunks/duration)
- generate.rs: generate span + generate_end event (provider/model/modality, ok/duration/finish_reason)
- FFI: aimux_init_logging C ABI export + header declaration; providers re-export init_logging
- Tests: unit (idempotency, filter precedence via event capture, redaction) + wiremock integration (retry chain, summaries, body redaction, stream events); workspace suite green

* fix(logging): collapse nested ifs (clippy collapsible_if, deny warnings)

* fix(logging): disable ANSI in test capture subscribers

fmt() auto-detects ANSI support; on CI (GitHub Actions) it emits color codes that split field rendering (status=429 -> status^[[2m=^[[0m429), breaking literal contains() assertions. with_ansi(false) makes captured output deterministic across platforms.

* docs(rfc-0014): correct stale provider count (172 -> 250 registry entries)

* docs(rfc-0014): drop hardcoded provider count (172/250 -> count-agnostic)

* feat(logging): expose init_logging entry in all 8 bindings (RFC-0014)

Python: aimux.init_logging(level); Node: initLogging(level); Go: InitLogging(level) via cgo; Java: Aimux.initLogging(level) via JNA; Kotlin: initLogging(level); Swift: Aimux.initLogging(level:); Flutter/Dart: initLogging(level) via dart:ffi. C/Rust entries existed. All idempotent, no-op when host subscriber present, AIMUX_LOG* env takes precedence. Docs: per-language entry table in docs/api/rust.md.
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.

[bug] aimux-ffi 失败时丢失详细错误信息(影响 Go/Java/C 等 C ABI 调用方)

1 participant