Skip to content

feat(host): typed host callbacks via {.ffiHost.} (WIP, increments 1-5)#82

Draft
Ivansete-status wants to merge 8 commits into
ffi-native-typed-returnsfrom
ffi-host-callbacks
Draft

feat(host): typed host callbacks via {.ffiHost.} (WIP, increments 1-5)#82
Ivansete-status wants to merge 8 commits into
ffi-native-typed-returnsfrom
ffi-host-callbacks

Conversation

@Ivansete-status

Copy link
Copy Markdown
Collaborator

Status: draft / work-in-progress. Roadmap item #1 from docs/future-work.md — pausing here to resume later. Functionally complete for the raw string case, from Nim all the way to an idiomatic Go API.

What this adds

A new pragma {.ffiHost.} — the inverse of {.ffi.}. A bodyless

proc fetchToken(key: string): Future[Result[string, string]] {.ffiHost.}

declares a function the host implements; a {.ffi.} handler can await it. This is the "a lower layer needs to read from a higher one" case from logos-delivery #3865. Events are lib→host fire-and-forget; this is lib→host and back, awaited.

Key constraint (see docs/design-host-callbacks.md): a host answering from its own thread can't complete a chronos Future directly, so completion mirrors the request path in reverse — park the answer + signal, drain and complete on the FFI loop thread. Each call carries a callId correlation id; a late/unknown completion is dropped, never a crash.

Increments (all landed, orc+refc green)

  1. ffi/ffi_host.nimFFIHostRegistry + FFIPendingTable (lock-guarded data structures) + unit tests.
  2. Completion bridge on FFIContextFFICompletionQueue (GC-free c_malloc nodes); completeHostCall parks the answer and fires the existing reqSignal (no second signal); the loop drains each iteration; failAllPending on shutdown so an unanswered call can't hang teardown.
  3. The {.ffiHost.} macro — generates the async body (resolve thread-local registry/table → look up by snake_case wire name → newPending a callId → invoke host fn with the raw request → await).
  4. C ABIdeclareLibrary emits <lib>_register_host_fn / <lib>_host_complete; c.nim emits the FFIHostFn typedef + decls. Cross-thread e2e (test_ffi_host_e2e): a {.ffi.} handler awaits a host fn answered from a separate worker thread.
  5. FFIHostMeta registry + Go generator — a //export cgo trampoline + cgo.Handle + per-host Set<Name>(func(string) (string, error)); the closure runs on a goroutine (non-blocking contract) then answers via host_complete. Validated by go run in examples/host_demo/.

Plus a tokencallId rename for clarity.

Validation

  • 16 host unit tests (orc + refc).
  • 19 ffi_context integration tests stay green (no regression).
  • examples/host_demo Go round-trip via go run: Go UseToken → Nim {.ffi.}await fetchToken {.ffiHost.} → Go trampoline → goroutine → host_complete → result back in Go.

Not done yet (resume points)

🤖 Generated with Claude Code

Ivansete-status and others added 8 commits June 13, 2026 20:51
Captures the "super FFI" roadmap (future-work.md) and a concrete design for
item #1, typed host callbacks via {.ffiHost.} (design-host-callbacks.md),
including the per-language host-consumption sketches and the non-blocking
FFI-thread contract the generated wrappers must enforce.

The design is grounded in the existing threading model: a host answering from
its own thread cannot complete a chronos Future directly, so the completion
path mirrors the request path in reverse — enqueue + ThreadSignalPtr signal,
drained and completed on the FFI (event-loop) thread.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
First increment of typed host callbacks (roadmap #1): the data-structure
layer, independent of the FFI thread and the macro so it can be unit-tested
in isolation.

- FFIHostRegistry: wire-name -> (host fn ptr, userData). A missing entry is a
  normal outcome (the imported proc errors), never a crash — never-crash
  policy. nil fn unregisters.
- FFIPendingTable: monotonic token -> the chronos Future an awaiting
  {.ffiHost.} proc is blocked on. completePending drops unknown/double
  completions; failAllPending errors every outstanding future on teardown so
  no awaiting handler is abandoned.

Both lock-guarded so a host thread and the FFI thread can touch them
concurrently; futures are only ever completed on the FFI thread. 6 unit tests
pass under orc and refc.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Increment 2: wires the host-call machinery into the running FFI thread so a
host answer (delivered from any thread) resolves the chronos Future an awaiting
handler is blocked on.

- FFICompletionQueue (ffi_host.nim): a GC-free intrusive queue. host_complete
  pushes c_malloc'd nodes from any thread; the FFI thread drains, copies the
  payload into GC memory, completes the future by token, and frees the node.
- FFIContext gains hostRegistry / pendingTable / completionQueue, init'd and
  deinit'd alongside the event registry.
- completeHostCall parks the answer and fires the EXISTING reqSignal — no second
  ThreadSignalPtr needed; the loop drains completions every iteration, on the
  loop thread (chronos single-thread invariant).
- On shutdown the loop failAllPendings first, so a handler awaiting a host
  answer that never arrives can't hang the allFutures(pending) drain.

4 new queue unit tests (10 total) pass under orc+refc; the 19 ffi_context
integration tests stay green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Records the user-chosen direction for telling the compiler a proc's wire
format, applicable across the whole pragma family ({.ffi.}, {.ffiHost.}, …):

- per-proc pragma argument: {.ffi: raw.} / {.ffi: cbor.} (A1), default native;
- library-wide override declareLibrary(defaultAbiFormat = raw) (C2);
- the global -d:ffiMode compile flag is discarded.

'raw' (not 'native') names the zero-serialization C-POD format. Design only;
captures open questions (symbol emission, resolution order, codegen impact) for
further discussion before implementation.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Increment 3: the {.ffiHost.} pragma. A bodyless
  proc fetchToken(key: string): Future[Result[string, string]] {.ffiHost.}
expands into an async proc that resolves the thread-local host registry +
pending table, looks the fn up by snake_case wire name, allocates a token,
invokes the host with the raw request bytes, and awaits the answer.

This is the inverse of {.ffi.} and the first end-to-end use of the registry
(increment 1) + completion bridge (increment 2). First slice is deliberately
narrow — raw ABI, one string param, Future[Result[string, string]] — to prove
the round-trip with zero serialization; struct params/returns and the
{.ffiHost: cbor.} format arg are follow-ups.

The body reads two new threadvars (ffiCurrentHostRegistry / ffiCurrentPendingTable)
set by ffiThreadBody alongside ffiCurrentEventRegistry, so the user's signature
stays ctx-free. The host fn is invoked synchronously before the await, while the
string arg is still alive (honouring the "req valid only for the call" contract).

5 macro tests pass under orc+refc; host + ffi_context suites stay green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Increment 4: the exported C surface for host callbacks, plus an end-to-end
test that the host can answer from a different thread than the FFI loop.

- declareLibrary now emits two exportc/cdecl procs on every library's
  FFIContext (like the event ABI):
    <lib>_register_host_fn(ctx, name, fn, userData)
    <lib>_host_complete(ctx, token, ret, msg, len)
  (the `name` param is spelled `hostFnName` to dodge the macros.name capture
  under quote, same class as the existing id/ret collisions.)
- c.nim emits the FFIHostFn typedef + both declarations into <lib>.h
  (guarded, format-agnostic), and the timer header is regenerated.
- Verified: the built timer lib exports both symbols.

The e2e (test_ffi_host_e2e) drives the real bridge: a {.ffi.} handler awaits a
{.ffiHost.} call; the host fn (invoked on the FFI thread, non-blocking) hands
the work to a worker thread, which answers via the completion path. The result
resolves on the loop thread and round-trips correctly (orc+refc). It calls the
underlying registerHostFn/completeHostCall directly, since the exported shims
need an --app:lib build; those shims are verified by the symbol check.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
5a: record {.ffiHost.} procs in a compile-time registry (FFIHostMeta /
ffiHostRegistry), populated by the macro, so generators can see host fns.

5b: the Go generator emits an idiomatic wrapper over the host C ABI:
- a single //export cgo trampoline backs every host fn; a cgo.Handle in
  userData selects the Go closure;
- the closure runs on a fresh GOROUTINE so the FFI thread is never blocked
  (the non-blocking contract), then answers via <lib>_host_complete by token;
- a per-host `Set<Name>(func(string) (string, error))` method registers it.

Validated end to end with `go run` (examples/host_demo): Go UseToken -> Nim
{.ffi.} handler -> await fetchToken {.ffiHost.} -> Go trampoline -> goroutine
runs the closure -> host_complete -> future resolves on the loop thread ->
"token[TOK-session]" back in Go. Timer's Go output is unchanged (no host fns);
its regenerated .h just gains the always-exported host ABI decls.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
"token" was overloaded (auth tokens, cgo handles, lexer tokens) and didn't say
what it is — a per-call correlation id linking an outgoing {.ffiHost.} call to
the answer that arrives later (possibly from another thread). Renamed across the
runtime (ffi_host / ffi_context), the macro, the exported C ABI (FFIHostFn,
<lib>_host_complete), the Go trampoline, and the tests; regenerated bindings.

The unrelated request-path cgo.Handle result-slot (also informally called a
"token" in go.nim comments) is left as-is — different mechanism.

16 host unit tests + the examples/host_demo Go round-trip stay green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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.

1 participant