Skip to content

Commit 7b028e6

Browse files
feat(ffi): RET_STALE_WARN progress callback replacing handler timeout (#129)
1 parent e0faefd commit 7b028e6

25 files changed

Lines changed: 362 additions & 279 deletions

CHANGELOG.md

Lines changed: 11 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -42,16 +42,17 @@ All notable changes to this project are documented in this file.
4242
`nimble genbindings_c` / `genbindings_c_echo` / `check_bindings_c` /
4343
`test_c_e2e` tasks, a `tests/e2e/c` ctest harness, and a
4444
`tests/unit/test_c_codegen.nim` unit suite.
45-
- Configurable per-request handler timeout with a finite default: each
46-
`FFIContext` now carries a `defaultRequestTimeout` (5s) applied to every
47-
handler, replacing the previous unbounded wait so a wedged handler can no
48-
longer hang a foreign caller forever. On trip the caller is unblocked with an
49-
`ffi request timed out after <n>ms` err; the handler is left running (not
50-
cancelled, since a hard-cancel mid-call into the underlying library can leave
51-
it partial), and the callback still fires exactly once. Override per proc with
52-
a `"timeout = <ms>"` spec (e.g. `{.ffi: "timeout = 30000".}`), parsed like the
53-
`abi = ...` spec; runtime-only, codegen ignores it
54-
([#93](https://github.com/logos-messaging/nim-ffi/issues/93)).
45+
- Non-terminal `RET_STALE_WARN` (3) progress callback in place of a handler
46+
timeout: nim-ffi never times a handler out (a hard-cancel mid-call into the
47+
underlying library can leave it half-applied). Instead, while a request is
48+
still in flight its result callback receives a `RET_STALE_WARN` every 5s
49+
(Android's ANR interval; override with `-d:ffiStaleWarnIntervalMs=<ms>`), with
50+
the payload carrying the elapsed milliseconds as a decimal string. The request
51+
always ends with exactly one terminal `RET_OK` / `RET_ERR`; the dev decides
52+
what to do with a slow one. Replaces the never-released per-proc
53+
`{.ffi: "timeout = <ms>".}` override and the `defaultRequestTimeout` context
54+
field ([#126](https://github.com/logos-messaging/nim-ffi/issues/126),
55+
supersedes [#93](https://github.com/logos-messaging/nim-ffi/issues/93)).
5556
- Per-interaction ABI-format annotations: `declareLibrary` now takes an
5657
optional `defaultABIFormat` (`"cbor"` default, or `"c"`) that every
5758
`{.ffi.}` / `{.ffiCtor.}` / `{.ffiDtor.}` / `{.ffiRaw.}` / `{.ffiEvent.}`

README.md

Lines changed: 27 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -91,26 +91,33 @@ Every `{.ffi.}` / `{.ffiCtor.}` proc must have an explicit
9191
`return ok(...)` without awaiting). The `Result`'s error string is delivered to
9292
the foreign caller as the failure message.
9393

94-
### Request timeouts
95-
96-
Every handler runs under a deadline. The default is `DefaultRequestTimeout`
97-
(5s, `ffi/ffi_context.nim`), applied to every proc so a wedged handler can't
98-
hang a foreign caller forever. On trip the caller is unblocked with an `ffi
99-
request timed out after <n>ms` error; the handler is **not** cancelled — a
100-
hard cancel mid-call into the underlying library can leave it half-applied — so
101-
it keeps running, and the caller's callback still fires exactly once.
102-
103-
Raise or lower the deadline per proc with a `"timeout = <ms>"` spec, parsed
104-
like the `abi = ...` spec below:
105-
106-
```nim
107-
proc slowOp*(
108-
c: Counter, req: BumpRequest
109-
): Future[Result[BumpResponse, string]] {.ffi: "timeout = 30000".} =
110-
...
111-
```
112-
113-
The timeout is runtime-only; binding codegen ignores it.
94+
### The result callback contract
95+
96+
Each request carries a result callback. It receives one of these status codes
97+
(`ret` / `err_code`):
98+
99+
| Code | Value | Terminal? | Meaning |
100+
| --- | --- | --- | --- |
101+
| `RET_OK` | 0 | yes | Success; the payload carries the encoded result. |
102+
| `RET_ERR` | 1 | yes | Failure; the payload carries the UTF-8 error string. |
103+
| `RET_MISSING_CALLBACK` | 2 || No callback was passed; the request path reports this itself. |
104+
| `RET_STALE_WARN` | 3 | **no** | Progress ping — the handler is still running. |
105+
106+
**nim-ffi never times a handler out.** A slow request runs to its natural
107+
`RET_OK` / `RET_ERR`; it is never cancelled (a hard-cancel mid-call into the
108+
underlying library can leave it half-applied). Instead, while a handler is still
109+
in flight the callback receives a **non-terminal** `RET_STALE_WARN` every 5s
110+
(Android's ANR interval; override at build time with
111+
`-d:ffiStaleWarnIntervalMs=<ms>`), with the payload carrying the elapsed
112+
milliseconds as a decimal string. The dev decides what to do with a slow request
113+
— keep waiting, surface a spinner, tear the context down — nim-ffi does not
114+
decide for them.
115+
116+
`RET_STALE_WARN` may fire any number of times and is **always** followed by
117+
exactly one terminal `RET_OK` / `RET_ERR`. A caller that only wants the final
118+
answer must ignore it (do not treat a non-zero code as an error without checking
119+
for `RET_STALE_WARN` first). The generated higher-level typed wrappers currently
120+
ignore it; the progress signal is delivered at the raw result-callback boundary.
114121

115122
### Events
116123

examples/echo/c_abi_bindings/echo.h

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,10 @@
99
#define NIMFFI_RET_OK 0
1010
#define NIMFFI_RET_ERR 1
1111
#define NIMFFI_RET_MISSING_CALLBACK 2
12+
/* Non-terminal: the request is still running. Fires every ~5s with `msg`
13+
carrying the elapsed milliseconds as decimal text; always followed by a
14+
terminal RET_OK/RET_ERR. Ignore it unless you want progress. */
15+
#define NIMFFI_RET_STALE_WARN 3
1216

1317
/* Flat wire structs — the C ABI. Strings are borrowed, NUL-terminated
1418
`const char*` valid only for the duration of the call they cross. */
@@ -54,6 +58,8 @@ typedef struct { EchoCreateFn fn; void* user_data; } EchoCreateBox;
5458
static void echo_create_trampoline(int ret, const char* ctx_addr, const char* err_msg, void* ud) {
5559
EchoCreateBox* box = (EchoCreateBox*)ud;
5660
if (!box) return;
61+
/* Non-terminal progress ping: keep the box for the terminal reply. */
62+
if (ret == NIMFFI_RET_STALE_WARN) return;
5763
if (!box->fn) { free(box); return; }
5864
if (ret != 0) {
5965
box->fn(ret, NULL, err_msg ? err_msg : "FFI create failed", box->user_data);

examples/echo/c_bindings/echo.h

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -222,6 +222,8 @@ typedef void (*EchoCreateFn)(int err_code, EchoCtx* ctx, const char* err_msg, vo
222222
typedef struct { EchoCreateFn fn; void* user_data; } EchoCreateBox;
223223
static void echo_create_trampoline(int ret, const char* msg, size_t len, void* ud) {
224224
EchoCreateBox* box = (EchoCreateBox*)ud;
225+
/* Non-terminal progress ping: keep the box for the terminal reply. */
226+
if (ret == NIMFFI_RET_STALE_WARN) return;
225227
if (!box->fn) {
226228
free(box);
227229
return;
@@ -297,6 +299,8 @@ typedef void (*EchoShoutReplyFn)(int err_code, const ShoutResponse* reply, const
297299
typedef struct { EchoShoutReplyFn fn; void* user_data; } EchoShoutCallBox;
298300
static void echo_shout_reply_trampoline(int ret, const char* msg, size_t len, void* ud) {
299301
EchoShoutCallBox* box = (EchoShoutCallBox*)ud;
302+
/* Non-terminal progress ping: keep the box for the terminal reply. */
303+
if (ret == NIMFFI_RET_STALE_WARN) return;
300304
if (!box->fn) {
301305
free(box);
302306
return;
@@ -357,6 +361,8 @@ typedef void (*EchoVersionReplyFn)(int err_code, const NimFfiStr* reply, const c
357361
typedef struct { EchoVersionReplyFn fn; void* user_data; } EchoVersionCallBox;
358362
static void echo_version_reply_trampoline(int ret, const char* msg, size_t len, void* ud) {
359363
EchoVersionCallBox* box = (EchoVersionCallBox*)ud;
364+
/* Non-terminal progress ping: keep the box for the terminal reply. */
365+
if (ret == NIMFFI_RET_STALE_WARN) return;
360366
if (!box->fn) {
361367
free(box);
362368
return;

examples/echo/c_bindings/nim_ffi_cbor.h

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,10 +20,17 @@ typedef void (*FFICallback)(int ret, const char* msg, size_t len, void* user_dat
2020
* value handed to a result callback's `err_code` (or returned by a submit call)
2121
* is a failure. NIMFFI_RET_MISSING_CALLBACK is a special case from the Nim
2222
* dispatcher: the callback will never fire, so the request path must report the
23-
* failure itself. */
23+
* failure itself.
24+
*
25+
* NIMFFI_RET_STALE_WARN is the one NON-terminal code: nim-ffi delivers it every
26+
* ~5s while a handler is still running (with `msg`/`len` carrying the elapsed
27+
* milliseconds as decimal text), then still ends with a terminal RET_OK/RET_ERR.
28+
* A caller that only wants the final answer must ignore it, not treat it as an
29+
* error. */
2430
#define NIMFFI_RET_OK 0
2531
#define NIMFFI_RET_ERROR 1
2632
#define NIMFFI_RET_MISSING_CALLBACK 2
33+
#define NIMFFI_RET_STALE_WARN 3
2734

2835
/* ── leaf encoders ─────────────────────────────────────────────────────── */
2936
static inline CborError nimffi_enc_bool(CborEncoder* e, const bool* v) {

examples/echo/cpp_bindings/echo.hpp

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,16 @@ extern "C" {
2929
#include <tinycbor/cbor.h>
3030
}
3131

32+
// nim-ffi result-callback status codes (mirror ffi/ffi_types.nim and the C
33+
// header). Guarded so a translation unit that also pulls in the C header keeps
34+
// a single definition.
35+
#ifndef NIMFFI_RET_OK
36+
#define NIMFFI_RET_OK 0
37+
#define NIMFFI_RET_ERR 1
38+
#define NIMFFI_RET_MISSING_CALLBACK 2
39+
#define NIMFFI_RET_STALE_WARN 3
40+
#endif
41+
3242
// ============================================================
3343
// Result<T> — exception-free error channel
3444
// ============================================================
@@ -448,14 +458,20 @@ struct FFICallState_ {
448458
};
449459

450460
inline void ffi_cb_(int ret, const char* msg, size_t len, void* ud) {
461+
// NIMFFI_RET_STALE_WARN (3) is a non-terminal progress ping: the request is
462+
// still running. This blocking wrapper only reports the final result, so
463+
// ignore it WITHOUT touching `ud` — a terminal callback still owns the
464+
// shared handle and will free it.
465+
if (ret == NIMFFI_RET_STALE_WARN) return;
466+
451467
// ffi_call_ heap-allocated a shared_ptr and passed its address as ud;
452468
// take ownership here so it's freed on every exit path.
453469
std::unique_ptr<std::shared_ptr<FFICallState_>> handle(
454470
static_cast<std::shared_ptr<FFICallState_>*>(ud));
455471
FFICallState_& s = **handle;
456472

457473
std::lock_guard<std::mutex> lock(s.mtx);
458-
s.ok = (ret == 0);
474+
s.ok = (ret == NIMFFI_RET_OK);
459475
if (msg && len > 0) {
460476
const auto* p = reinterpret_cast<const std::uint8_t*>(msg);
461477
if (s.ok) s.bytes.assign(p, p + len);
@@ -472,7 +488,7 @@ inline Result<std::vector<std::uint8_t>> ffi_call_(
472488
auto state = std::make_shared<FFICallState_>();
473489
auto* cb_ref = new std::shared_ptr<FFICallState_>(state);
474490
const int ret = f(ffi_cb_, cb_ref);
475-
if (ret == 2) {
491+
if (ret == NIMFFI_RET_MISSING_CALLBACK) {
476492
delete cb_ref;
477493
return Result<Bytes>::err("RET_MISSING_CALLBACK (internal error)");
478494
}

examples/timer/c_bindings/my_timer.h

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -829,6 +829,8 @@ typedef void (*MyTimerCreateFn)(int err_code, MyTimerCtx* ctx, const char* err_m
829829
typedef struct { MyTimerCreateFn fn; void* user_data; } MyTimerCreateBox;
830830
static void my_timer_create_trampoline(int ret, const char* msg, size_t len, void* ud) {
831831
MyTimerCreateBox* box = (MyTimerCreateBox*)ud;
832+
/* Non-terminal progress ping: keep the box for the terminal reply. */
833+
if (ret == NIMFFI_RET_STALE_WARN) return;
832834
if (!box->fn) {
833835
free(box);
834836
return;
@@ -940,6 +942,8 @@ typedef void (*MyTimerEchoReplyFn)(int err_code, const EchoResponse* reply, cons
940942
typedef struct { MyTimerEchoReplyFn fn; void* user_data; } MyTimerEchoCallBox;
941943
static void my_timer_echo_reply_trampoline(int ret, const char* msg, size_t len, void* ud) {
942944
MyTimerEchoCallBox* box = (MyTimerEchoCallBox*)ud;
945+
/* Non-terminal progress ping: keep the box for the terminal reply. */
946+
if (ret == NIMFFI_RET_STALE_WARN) return;
943947
if (!box->fn) {
944948
free(box);
945949
return;
@@ -1000,6 +1004,8 @@ typedef void (*MyTimerVersionReplyFn)(int err_code, const NimFfiStr* reply, cons
10001004
typedef struct { MyTimerVersionReplyFn fn; void* user_data; } MyTimerVersionCallBox;
10011005
static void my_timer_version_reply_trampoline(int ret, const char* msg, size_t len, void* ud) {
10021006
MyTimerVersionCallBox* box = (MyTimerVersionCallBox*)ud;
1007+
/* Non-terminal progress ping: keep the box for the terminal reply. */
1008+
if (ret == NIMFFI_RET_STALE_WARN) return;
10031009
if (!box->fn) {
10041010
free(box);
10051011
return;
@@ -1059,6 +1065,8 @@ typedef void (*MyTimerComplexReplyFn)(int err_code, const ComplexResponse* reply
10591065
typedef struct { MyTimerComplexReplyFn fn; void* user_data; } MyTimerComplexCallBox;
10601066
static void my_timer_complex_reply_trampoline(int ret, const char* msg, size_t len, void* ud) {
10611067
MyTimerComplexCallBox* box = (MyTimerComplexCallBox*)ud;
1068+
/* Non-terminal progress ping: keep the box for the terminal reply. */
1069+
if (ret == NIMFFI_RET_STALE_WARN) return;
10621070
if (!box->fn) {
10631071
free(box);
10641072
return;
@@ -1119,6 +1127,8 @@ typedef void (*MyTimerScheduleReplyFn)(int err_code, const ScheduleResult* reply
11191127
typedef struct { MyTimerScheduleReplyFn fn; void* user_data; } MyTimerScheduleCallBox;
11201128
static void my_timer_schedule_reply_trampoline(int ret, const char* msg, size_t len, void* ud) {
11211129
MyTimerScheduleCallBox* box = (MyTimerScheduleCallBox*)ud;
1130+
/* Non-terminal progress ping: keep the box for the terminal reply. */
1131+
if (ret == NIMFFI_RET_STALE_WARN) return;
11221132
if (!box->fn) {
11231133
free(box);
11241134
return;

examples/timer/c_bindings/nim_ffi_cbor.h

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,10 +20,17 @@ typedef void (*FFICallback)(int ret, const char* msg, size_t len, void* user_dat
2020
* value handed to a result callback's `err_code` (or returned by a submit call)
2121
* is a failure. NIMFFI_RET_MISSING_CALLBACK is a special case from the Nim
2222
* dispatcher: the callback will never fire, so the request path must report the
23-
* failure itself. */
23+
* failure itself.
24+
*
25+
* NIMFFI_RET_STALE_WARN is the one NON-terminal code: nim-ffi delivers it every
26+
* ~5s while a handler is still running (with `msg`/`len` carrying the elapsed
27+
* milliseconds as decimal text), then still ends with a terminal RET_OK/RET_ERR.
28+
* A caller that only wants the final answer must ignore it, not treat it as an
29+
* error. */
2430
#define NIMFFI_RET_OK 0
2531
#define NIMFFI_RET_ERROR 1
2632
#define NIMFFI_RET_MISSING_CALLBACK 2
33+
#define NIMFFI_RET_STALE_WARN 3
2734

2835
/* ── leaf encoders ─────────────────────────────────────────────────────── */
2936
static inline CborError nimffi_enc_bool(CborEncoder* e, const bool* v) {

examples/timer/cpp_bindings/my_timer.hpp

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,16 @@ extern "C" {
2929
#include <tinycbor/cbor.h>
3030
}
3131

32+
// nim-ffi result-callback status codes (mirror ffi/ffi_types.nim and the C
33+
// header). Guarded so a translation unit that also pulls in the C header keeps
34+
// a single definition.
35+
#ifndef NIMFFI_RET_OK
36+
#define NIMFFI_RET_OK 0
37+
#define NIMFFI_RET_ERR 1
38+
#define NIMFFI_RET_MISSING_CALLBACK 2
39+
#define NIMFFI_RET_STALE_WARN 3
40+
#endif
41+
3242
#include <unordered_map>
3343
// ============================================================
3444
// Result<T> — exception-free error channel
@@ -748,14 +758,20 @@ struct FFICallState_ {
748758
};
749759

750760
inline void ffi_cb_(int ret, const char* msg, size_t len, void* ud) {
761+
// NIMFFI_RET_STALE_WARN (3) is a non-terminal progress ping: the request is
762+
// still running. This blocking wrapper only reports the final result, so
763+
// ignore it WITHOUT touching `ud` — a terminal callback still owns the
764+
// shared handle and will free it.
765+
if (ret == NIMFFI_RET_STALE_WARN) return;
766+
751767
// ffi_call_ heap-allocated a shared_ptr and passed its address as ud;
752768
// take ownership here so it's freed on every exit path.
753769
std::unique_ptr<std::shared_ptr<FFICallState_>> handle(
754770
static_cast<std::shared_ptr<FFICallState_>*>(ud));
755771
FFICallState_& s = **handle;
756772

757773
std::lock_guard<std::mutex> lock(s.mtx);
758-
s.ok = (ret == 0);
774+
s.ok = (ret == NIMFFI_RET_OK);
759775
if (msg && len > 0) {
760776
const auto* p = reinterpret_cast<const std::uint8_t*>(msg);
761777
if (s.ok) s.bytes.assign(p, p + len);
@@ -772,7 +788,7 @@ inline Result<std::vector<std::uint8_t>> ffi_call_(
772788
auto state = std::make_shared<FFICallState_>();
773789
auto* cb_ref = new std::shared_ptr<FFICallState_>(state);
774790
const int ret = f(ffi_cb_, cb_ref);
775-
if (ret == 2) {
791+
if (ret == NIMFFI_RET_MISSING_CALLBACK) {
776792
delete cb_ref;
777793
return Result<Bytes>::err("RET_MISSING_CALLBACK (internal error)");
778794
}

examples/timer/rust_bindings/src/api.rs

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -31,16 +31,26 @@ unsafe fn ffi_payload(ret: c_int, msg: *const c_char, len: usize) -> FFIResult {
3131
} else {
3232
slice::from_raw_parts(msg as *const u8, len).to_vec()
3333
};
34-
if ret == 0 { Ok(bytes) }
34+
if ret == NIMFFI_RET_OK { Ok(bytes) }
3535
else { Err(String::from_utf8_lossy(&bytes).into_owned()) }
3636
}
3737

38+
// nim-ffi result-callback status codes (mirror ffi/ffi_types.nim).
39+
const NIMFFI_RET_OK: c_int = 0;
40+
const NIMFFI_RET_MISSING_CALLBACK: c_int = 2;
41+
const NIMFFI_RET_STALE_WARN: c_int = 3;
42+
3843
unsafe extern "C" fn on_result(
3944
ret: c_int,
4045
msg: *const c_char,
4146
len: usize,
4247
user_data: *mut c_void,
4348
) {
49+
// NIMFFI_RET_STALE_WARN (3) is a non-terminal progress ping: the request
50+
// is still running. This wrapper only delivers the final result, so ignore
51+
// it WITHOUT reclaiming the box — a terminal callback still owns the Sender.
52+
if ret == NIMFFI_RET_STALE_WARN { return; }
53+
4454
// Take ownership of the boxed Sender — dropping it at end of scope
4555
// releases the only outstanding handle.
4656
let tx = Box::from_raw(user_data as *mut FFISender);
@@ -66,7 +76,7 @@ where
6676
let (tx, rx) = flume::bounded::<FFIResult>(1);
6777
let raw = Box::into_raw(Box::new(tx)) as *mut c_void;
6878
let ret = f(on_result, raw);
69-
if ret == 2 {
79+
if ret == NIMFFI_RET_MISSING_CALLBACK {
7080
// Callback will never fire; reclaim the box to avoid a leak.
7181
drop(unsafe { Box::from_raw(raw as *mut FFISender) });
7282
return Err("RET_MISSING_CALLBACK (internal error)".into());
@@ -87,7 +97,7 @@ where
8797
let (tx, rx) = flume::bounded::<FFIResult>(1);
8898
let raw = Box::into_raw(Box::new(tx)) as *mut c_void;
8999
let ret = f(on_result, raw);
90-
if ret == 2 {
100+
if ret == NIMFFI_RET_MISSING_CALLBACK {
91101
drop(unsafe { Box::from_raw(raw as *mut FFISender) });
92102
return Err("RET_MISSING_CALLBACK (internal error)".into());
93103
}

0 commit comments

Comments
 (0)