feat(host): typed host callbacks via {.ffiHost.} (WIP, increments 1-5)#82
Draft
Ivansete-status wants to merge 8 commits into
Draft
feat(host): typed host callbacks via {.ffiHost.} (WIP, increments 1-5)#82Ivansete-status wants to merge 8 commits into
Ivansete-status wants to merge 8 commits into
Conversation
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 bodylessdeclares a function the host implements; a
{.ffi.}handler canawaitit. 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 chronosFuturedirectly, so completion mirrors the request path in reverse — park the answer + signal, drain and complete on the FFI loop thread. Each call carries acallIdcorrelation id; a late/unknown completion is dropped, never a crash.Increments (all landed, orc+refc green)
ffi/ffi_host.nim—FFIHostRegistry+FFIPendingTable(lock-guarded data structures) + unit tests.FFIContext—FFICompletionQueue(GC-freec_mallocnodes);completeHostCallparks the answer and fires the existingreqSignal(no second signal); the loop drains each iteration;failAllPendingon shutdown so an unanswered call can't hang teardown.{.ffiHost.}macro — generates the async body (resolve thread-local registry/table → look up by snake_case wire name →newPendingacallId→ invoke host fn with the raw request →await).declareLibraryemits<lib>_register_host_fn/<lib>_host_complete;c.nimemits theFFIHostFntypedef + decls. Cross-thread e2e (test_ffi_host_e2e): a{.ffi.}handler awaits a host fn answered from a separate worker thread.FFIHostMetaregistry + Go generator — a//exportcgo trampoline +cgo.Handle+ per-hostSet<Name>(func(string) (string, error)); the closure runs on a goroutine (non-blocking contract) then answers viahost_complete. Validated bygo runinexamples/host_demo/.Plus a
token→callIdrename for clarity.Validation
ffi_contextintegration tests stay green (no regression).examples/host_demoGo round-trip viago run: GoUseToken→ Nim{.ffi.}→await fetchToken{.ffiHost.}→ Go trampoline → goroutine →host_complete→ result back in Go.Not done yet (resume points)
{.ffiHost: cbor.}format arg (decision recorded indocs/design-abi-format.md; defaultraw, library-widedeclareLibrary(defaultAbiFormat = …)).🤖 Generated with Claude Code