Skip to content

feat(ffi)!: AimuxError out-param transport + language error models - #91

Merged
eric8810 merged 3 commits into
arcships:masterfrom
cunninghamcard-bit:feat/ffi-error-transport
Aug 9, 2026
Merged

feat(ffi)!: AimuxError out-param transport + language error models#91
eric8810 merged 3 commits into
arcships:masterfrom
cunninghamcard-bit:feat/ffi-error-transport

Conversation

@cunninghamcard-bit

Copy link
Copy Markdown
Contributor

Summary

Breaking change to the C ABI error transport, plus idiomatic error surfaces for every binding language.

Transport (Rust → C)

  • Fallible APIs use return-value sentinels (0 / NULL / stream 0) for success vs failure.
  • Optional trailing AimuxError *err carries code (0–19), status, retry_ms, and message (see aimux-error.h).
  • No JSON error envelopes on the main path. Check the return value first; only then read *err.
  • Stream: no C on_error callback; terminal failures return 0 + fill err. StreamPart::Error remains data on on_part.

Language surfaces (aligned to the same 18 core variants)

Binding Style
C AimuxError / AimuxErrorCode
Go Single *aimux.Error + errors.AsType (Go 1.26; openai-go style)
Node / Python Exception/Error class tree (direct Rust; same semantics)
Java / Kotlin / Flutter Exception class tree via C fromC
Swift enum AimuxError + associated values via fromC

Fields: message, HTTP status (or −1), retryMs/retry_ms (or −1; 0 = retry now).

Commits (review in order)

  1. feat(ffi)! — C transport
  2. docs (C API)
  3. small list_sessions signature fix
  4. Go → Node → Python → Java → Kotlin → Swift → Flutter
  5. fix: multi-agent review follow-ups (docs/tests stream polarity, channel encode, Kotlin fromC, Swift onError: AimuxError, …)

Migration (C consumers / JNA / cgo / dart:ffi)

Before: constructors returned JSON {"handle"} / {"error"}; failures often sniffed as envelopes.

After:

AimuxError err;
uint64_t h = aimux_openai_new(key, model, &err);
if (!h) { /* err.code, err.message, err.status, err.retry_ms */ }

Bindings that still documented envelope returns have been rewired (Java/Kotlin/Swift/Flutter/Go).

Test plan

  • cargo test -p aimux-ffi — error_detail / native_constructors / provider_handle (rewired for new ABI)
  • cargo check node + python bindings
  • Java compileTestJava
  • swift build against local libaimux_ffi
  • Full CI on this PR
  • Native E2E per language (mock server) where CI allows
  • Confirm downstream consumers of the C ABI have a migration note / release bump

Out of scope / follow-ups

  • Extra Go unit coverage for errorFromC / stream AsType
  • Kotlin Gradle JVM-target env mismatch on some machines
  • Flutter exact sizeOf == 66064 assert in CI with Flutter SDK
  • Tighter FFI classification of some input failures (Other vs InvalidArgument)

Docs

  • docs/api/c.md — transport contract
  • docs/api/{go,node,python,java,kotlin,swift,flutter}.md — Errors sections

@eric8810

eric8810 commented Aug 8, 2026

Copy link
Copy Markdown
Contributor

ci error

@cunninghamcard-bit

Copy link
Copy Markdown
Contributor Author

There is something wrong with my mac, wait a minute.

@cunninghamcard-bit
cunninghamcard-bit force-pushed the feat/ffi-error-transport branch from 6af8ffa to b407fdb Compare August 8, 2026 13:26
Replace JSON error envelopes with return-value sentinels plus an optional
trailing AimuxError *err out-param (GError-style transport):

- success: return the result (non-zero handle / non-NULL string / non-zero
  stream rc); *err is never touched. failure: sentinel + *err filled when
  non-NULL. The return value is the only success signal
- AimuxError is 24 bytes: code (0-19, append-only; 2..19 mirror the 18 core
  AiMuxError variants in declaration order), HTTP status or -1, retry_ms or
  -1, and a callee-allocated message freed via aimux_free_string
- classification: JSON parse failures -> AIMUX_E_JSON; null args and
  invalid handles -> AIMUX_E_INVALID_ARGUMENT
- streaming: no on_error callback; terminal failures return 0 + fill err;
  StreamPart::Error stays data on on_part; per-call stream_ctx replaces TLS
- error_value: lossless externally-tagged AiMuxError JSON alongside the
  Display message (null for boundary-synthesized failures); one reserved
  pointer slot keeps future ABI room in the frozen 40-byte struct
- tests: success-leaves-err-untouched, error_value round-trip, full 18-variant mapping,
  interior-NUL sanitization, arg/handle classification
- docs/api/c.md documents the contract; README updated
One conversion at the language boundary, typed values everywhere inside:

Node (napi): errors cross as structured fields, never encoded strings. The
tokio stream channel carries Result<String, MappedError>; the async
generator yields a StreamItem enum whose ToNapiValue impl (which has an
env) builds the throwable via create_throwable. TS owns the class tree:
AimuxError base + 18 table-driven subclasses with .code/.status/.retryMs,
captureStackTrace, cause chaining; raw re-exports wrapped so instanceof
holds; AimuxResult<T> declared via napi dtsHeader. Both surfaces expose errorValue / error_value: the raw externally-tagged
AiMuxError JSON (machine-readable companion to the message). Contract tests
cover sync/async/stream failures and instanceof narrowing.

Python (pyo3): base AimuxError + 18 create_exception! subclasses
(RateLimitError, AuthenticationError, APITimeoutError, ...) raised by one
to_py_err match; .status/.retry_ms are int | None. The stream channel
carries Result<String, AiMuxError> and converts once in __next__.
Each language projects the same 18 variants onto its native dispatch
construct, reading the 40-byte AimuxError out-param through one fromC mapping,
freeing message and error_value exactly once, and exposing errorValue as
the optional raw core-error JSON on each error surface:

- c: AimuxErrorCode switch; examples show the free discipline
- go: single *aimux.Error{Code, Message, Status, RetryMs} + errors.As
  (openai-go style); shared ffiString call helper; ctx cancellation wins
  over CodeAborted
- java: unchecked AimuxException + 18 subclasses; typed stream callbacks;
  jna.library.path wired into tests; arity smoke tests for every symbol
- kotlin: sealed AimuxException hierarchy (when-exhaustive); single
  checkError path; fixed put(null) end-of-stream NPE in sequences
- swift: enum AimuxError with associated values, Sendable, optional
  status/retryMs; one withCError lifecycle helper
- flutter: sealed-style exception tree via super-parameters;
  AimuxUnsupportedError/AimuxTimeoutError avoid shadowing dart:core;
  singleton FFI; leak-safe message handling
@cunninghamcard-bit
cunninghamcard-bit force-pushed the feat/ffi-error-transport branch from b407fdb to a9eac52 Compare August 8, 2026 14:08
@cunninghamcard-bit

Copy link
Copy Markdown
Contributor Author

设计脉络(对 #21 的订正)

#21 是当时和 AI 一起快速想出来的简单方案,没有太多设计:它正确地杀掉了 aimux_last_error 的 TLS 侧信道,但代价是成败复用同一个 JSON 返回值——每次调用(包括成功路径)都要解析信封、嗅探 "error" 键才知道结果。

后来研究了 GLib 的 GError 设计,确定改用错误指针传递:返回值是唯一的成败信号(0 / NULL / 流 0 = 失败),详情走可选出参 AimuxError *err——40 字节平结构体(code / status / retry_ms / message / error_value / 1 个预留位),任何路径不再解析 JSON。与 GError 两处有意偏离:

  1. 结构体由调用方分配(JNA/dart:ffi 直读字段,不追双重指针;只有两个字符串是被调方分配,复用 aimux_free_string 释放);
  2. 成功时被调方绝不写 *err(单信号纪律,有回归测试钉死)。

error_value 是 core AiMuxError 的无损 externally-tagged JSON——message 的机读伴生字段,延续 #17 / #19 / #21 一路的"错误细节不丢"保证,也是 provider 层保留原始数据的同一惯例。

流式路径的同类 TLS(__thread current_stream_id + 钉线程)#21 未处理,本 PR 用显式 stream_ctx 出参一并清掉——和当年杀 aimux_last_error 是同一味药。

传输之上,八个语言完成统一错误模型:同一份 18 个 core 变体,投影到各语言的原生分发构造——Python / Node / Java / Kotlin / Flutter 用类树(catch 按类型分发)、Go 单 *aimux.Error + errors.As、Swift 带关联值的 enum、C 是 code + struct;统一携带 message / status / retryMs / errorValue,且刻意不设 engine/local 层级:本地合成的失败映射到同一变体集。

@cunninghamcard-bit
cunninghamcard-bit marked this pull request as ready for review August 8, 2026 14:16
@eric8810
eric8810 merged commit c0fceba into arcships:master Aug 9, 2026
21 checks passed
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