Skip to content

feat(ffi): async teardown hook for {.ffiDtor.}#115

Open
gmelodie wants to merge 1 commit into
masterfrom
feat/teardown-hook
Open

feat(ffi): async teardown hook for {.ffiDtor.}#115
gmelodie wants to merge 1 commit into
masterfrom
feat/teardown-hook

Conversation

@gmelodie

@gmelodie gmelodie commented Jul 8, 2026

Copy link
Copy Markdown
Collaborator

Summary

Lets a library run async cleanup on the FFI worker thread during context teardown, and makes destroy block until it finishes. This is what unblocks an async libp2pDestroy that awaits switch.stop() so the switch is shut down gracefully — on the loop its chronos futures actually live on — before the FFI/event threads are joined and the context is freed.

Before this, a {.ffiDtor.} body ran synchronously on the host thread, with no seam to run async work on the worker at shutdown.

Mechanism

A non-empty {.ffiDtor.} body is lifted into an async impl registered in a per-library runtime hook slot (ffiTeardownHook[T](), one {.global.} per library type). After the FFI thread drains in-flight requests and before it exits, it awaits the hook on its own event loop. Exceptions are logged, never propagated — the thread must still fire threadExitSignal and exit cleanly.

Why a runtime slot and not an overloaded ffiTeardown resolved via mixin (the obvious design): the constructor force-instantiates ffiThreadBody[T] at its createFFIContext call, which happens before the dtor — declared later in the source, as every consumer writes it (ctor then dtor) — is visible. An overload would bind the no-op default at that instantiation, so the teardown would compile clean, destroy would return RET_OK, and the body would silently never run. Confirmed empirically: natural ctor-before-dtor order skips the teardown; dtor-first runs it. The runtime slot is assigned at module init and read at destroy, with zero compile-time ordering coupling.

Changes

  • ffi/ffi_context.nimFFITeardownProc[T] + ffiTeardownHook[T]() runtime slot. ThreadExitTimeout is now configurable via -d:ffiThreadExitTimeoutMs=<ms> (default unchanged at 1500): a graceful switch.stop() over many live connections can outlast the old fixed bound, and being cut short leaks the context instead of waiting.
  • ffi/ffi_thread.nim — await the registered hook at shutdown, guarded on hook-set and myLib-populated.
  • ffi/internal/ffi_macro.nim{.ffiDtor.} now accepts a sync (void) or async (Future[void]) signature, validated up front. Non-empty bodies are lifted into the hook impl; the C wrapper is reduced to nil-check + destroyFFIContext. An empty/discard body registers no hook, so existing no-op dtors (echo, timer, waku) are unchanged.

Testing

  • tests/unit/test_ffi_teardown.nim (new) — asserts destroy blocks until the async teardown body completes, and that it runs on the FFI thread (not the caller). Green under both --mm:orc and --mm:refc.
  • Verified back-compat: legacy sync-body dtors and the existing no-op discard dtors still compile and run; nimble check_bindings_c shows generated headers are unchanged; timer/echo examples still build; the -d:ffiThreadExitTimeoutMs knob compiles and takes effect.

Rollout note

Consumer PR: vacp2p/nim-libp2p#2773 (vacp2p/nim-libp2p#2773) re-pins nim-ffi to this commit and lands the async libp2pDestroy + shared shutdownSwitch + stopped guard.

That side should build with a larger -d:ffiThreadExitTimeoutMs — the default 1500 ms is likely too short for a graceful multi-connection switch.stop(), and a teardown that outlasts the bound leaks the context (returns RET_ERR) rather than hanging the caller.

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