Skip to content

Commit 3e57751

Browse files
authored
feat(ffi): async teardown hook for {.ffiDtor.} (#115)
1 parent 5eaa799 commit 3e57751

5 files changed

Lines changed: 190 additions & 27 deletions

File tree

ffi/event_thread.nim

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -116,20 +116,24 @@ proc eventRun[T](ctx: ptr FFIContext[T]) {.async.} =
116116
var hb = HeartbeatMonitor.init(ctx)
117117
var notifiedStuck = false # latched forever — eventQueueStuck is sticky terminal.
118118

119-
while ctx.running.load():
119+
# Keep draining after `running` flips false until the FFI thread has exited, so
120+
# events emitted by an async {.ffiDtor.} teardown are still dispatched.
121+
while ctx.running.load() or not ctx.ffiThreadExited.load():
120122
# Wake on enqueue or tick — whichever first.
121123
discard await ctx.eventQueueSignal.wait().withTimeout(EventThreadTickInterval)
122124

123125
ctx.drainEventQueue()
124126

125-
# Fire after drain so reg.lock is free — FFI-thread would deadlock here.
126-
if not notifiedStuck and ctx.eventQueueStuck.load():
127-
onNotResponding(ctx)
128-
notifiedStuck = true
127+
# Liveness only applies while running; skip it during the teardown drain.
128+
if ctx.running.load():
129+
# Fire after drain so reg.lock is free — FFI-thread would deadlock here.
130+
if not notifiedStuck and ctx.eventQueueStuck.load():
131+
onNotResponding(ctx)
132+
notifiedStuck = true
133+
hb.check(ctx)
129134

130-
if not ctx.running.load():
131-
break
132-
hb.check(ctx)
135+
# Catch anything enqueued between the last drain and the FFI thread's exit.
136+
ctx.drainEventQueue()
133137

134138
proc eventThreadBody[T](ctx: ptr FFIContext[T]) {.thread.} =
135139
## Drains the event queue and runs the FFI-thread heartbeat check.

ffi/ffi_context.nim

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,9 @@ type FFIContext*[T] = object
3737
ffiHeartbeat*: Atomic[int64]
3838
# advanced each FFI-thread loop; event thread reads for liveness
3939
eventQueueStuck*: Atomic[bool] # sticky overflow flag
40+
ffiThreadExited*: Atomic[bool]
41+
# set once the FFI thread (including any async {.ffiDtor.} teardown) is done;
42+
# keeps the event thread draining until then so teardown-emitted events land
4043
running: Atomic[bool] # To control when the threads are running
4144
registeredRequests: ptr Table[cstring, FFIRequestProc]
4245
requestTimeouts: ptr Table[cstring, int]
@@ -58,6 +61,19 @@ const
5861
DefaultRequestTimeout* = 5.seconds
5962
# Finite fallback (issue #93) so a wedged handler can't hang a caller forever.
6063

64+
type FFITeardownProc*[T] = proc(lib: ptr T): Future[void] {.async.}
65+
66+
proc ffiTeardownHook*[T](): var FFITeardownProc[T] =
67+
## Per-library teardown slot (one `{.global.}` per `T`), assigned at module init
68+
## by a non-empty `{.ffiDtor.}` and awaited by the FFI thread before it exits.
69+
##
70+
## A runtime slot, not an overloaded `ffiTeardown` resolved via `mixin`: the
71+
## constructor force-instantiates the FFI thread body before the dtor (declared
72+
## later in the source) is visible, so an overload would bind the no-op default
73+
## and the teardown would silently never run.
74+
var hook {.global.}: FFITeardownProc[T]
75+
hook
76+
6177
include ./event_thread
6278
include ./ffi_thread
6379

@@ -113,6 +129,7 @@ proc initContextResources*[T](ctx: ptr FFIContext[T]): Result[void, string] =
113129
initEventQueue(ctx[].eventQueue)
114130
ctx.ffiHeartbeat.store(0)
115131
ctx.eventQueueStuck.store(false)
132+
ctx.ffiThreadExited.store(false)
116133
ctx.defaultRequestTimeout = DefaultRequestTimeout
117134

118135
var success = false
@@ -180,9 +197,14 @@ proc signalStop*[T](ctx: ptr FFIContext[T]): Result[void, string] =
180197
error "failed to signal eventQueueSignal in signalStop", error = error
181198
ok()
182199

183-
## Bound on how long clearContext waits for the FFI thread to exit before
184-
## leaking ctx rather than hanging the caller.
185-
const ThreadExitTimeout* = 1500.milliseconds
200+
## Bound on how long stopAndJoinThreads waits for a worker thread to exit before
201+
## leaking ctx rather than hanging the caller. Configurable because an async
202+
## `{.ffiDtor.}` runs its teardown (e.g. `switch.stop()` over many live
203+
## connections) on the FFI thread before it exits — a graceful shutdown can
204+
## outlast the default, and being cut short leaks the context instead of waiting.
205+
## Override at compile time with `-d:ffiThreadExitTimeoutMs=<ms>`.
206+
const ThreadExitTimeoutMs* {.intdefine: "ffiThreadExitTimeoutMs".} = 1500
207+
const ThreadExitTimeout* = ThreadExitTimeoutMs.milliseconds
186208

187209
proc stopAndJoinThreads*[T](ctx: ptr FFIContext[T]): Result[void, string] =
188210
## On timeout, returns err and skips remaining joins (leaves threads live).

ffi/ffi_thread.nim

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,12 @@ proc ffiThreadBody[T](ctx: ptr FFIContext[T]) {.thread.} =
150150
onFFIThread = false
151151
# Free handle refs on the FFI thread that allocated them (refc heap is thread-local).
152152
ctx[].handles.releaseAll()
153+
# Teardown has run and no more events will be emitted from this thread; let
154+
# the event thread stop draining and exit. Wake it so it notices without
155+
# waiting a full tick.
156+
ctx.ffiThreadExited.store(true)
157+
ctx.eventQueueSignal.fireSync().isOkOr:
158+
error "failed to wake event thread on FFI thread exit", err = error
153159
# Unblocks destroyFFIContext's bounded wait so cleanup can proceed.
154160
let fireRes = ctx.threadExitSignal.fireSync()
155161
if fireRes.isErr():
@@ -210,4 +216,15 @@ proc ffiThreadBody[T](ctx: ptr FFIContext[T]) {.thread.} =
210216
except CatchableError as e:
211217
error "draining pending FFI requests on shutdown raised", error = e.msg
212218

219+
# In-flight requests drained; run the library's async shutdown (e.g.
220+
# `switch.stop()`) on this event loop before the thread joins. Only if a
221+
# `{.ffiDtor.}` registered a hook and a request populated `myLib`. Exceptions
222+
# are logged, never propagated: the thread must still fire threadExitSignal.
223+
let teardown = ffiTeardownHook[T]()
224+
if not teardown.isNil() and not ctx.myLib.isNil():
225+
try:
226+
await teardown(ctx.myLib)
227+
except CatchableError as e:
228+
error "library teardown raised on shutdown", error = e.msg
229+
213230
waitFor ffiRun(ctx)

ffi/internal/ffi_macro.nim

Lines changed: 50 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1575,26 +1575,38 @@ macro ffiCtor*(args: varargs[untyped]): untyped =
15751575
return stmts
15761576

15771577
macro ffiDtor*(args: varargs[untyped]): untyped =
1578-
## Defines a C-exported destructor that tears down the FFIContext after the
1579-
## body runs.
1578+
## Defines a C-exported destructor that tears down the FFIContext.
15801579
##
1581-
## The annotated proc must have exactly one parameter of the library type.
1582-
## The body contains any library-level cleanup to run before context teardown.
1580+
## The annotated proc must have exactly one parameter of the library type. It
1581+
## may be sync (no return type) or async (`Future[void]`) — an async dtor can
1582+
## `await` a graceful library shutdown (e.g. `switch.stop()`) whose futures
1583+
## live on the FFI event loop.
15831584
##
15841585
## The wire format follows the library default and can be overridden with
15851586
## `{.ffiDtor: "abi = c".}` / `{.ffiDtor: "abi = cbor".}`.
15861587
##
1587-
## Example:
1588-
## proc waku_destroy*(w: Waku) {.ffiDtor.} =
1589-
## w.cleanup()
1588+
## Example (sync):
1589+
## proc echo_destroy*(e: Echo) {.ffiDtor.} =
1590+
## e.close()
1591+
##
1592+
## Example (async):
1593+
## proc waku_destroy*(w: Waku): Future[void] {.ffiDtor.} =
1594+
## await w.stop()
15901595
##
15911596
## The generated C-exported proc has the signature:
15921597
## int waku_destroy(void* ctx)
15931598
##
1594-
## It extracts the library value from ctx, runs the body, then calls
1595-
## destroyFFIContext to tear down the FFI thread and free the context.
1599+
## A non-empty body is lifted into an async impl registered in the library's
1600+
## `ffiTeardownHook` slot; the FFI thread awaits it on its own event loop, after
1601+
## draining in-flight requests and just before it exits — so the body runs on
1602+
## the worker thread, not the host (calling) thread. The C wrapper signals the
1603+
## thread to stop and blocks (up to `ThreadExitTimeout`) until it, and the
1604+
## teardown, finish, then frees the context. An empty/`discard` body registers
1605+
## no hook.
1606+
##
15961607
## Returns RET_OK on success, RET_ERR on failure (null/invalid ctx, or
1597-
## destroyFFIContext failure).
1608+
## destroyFFIContext failure — e.g. a teardown that outlasts ThreadExitTimeout,
1609+
## which leaks the context rather than hanging the caller).
15981610

15991611
requireBeforeGenBindings("`.ffiDtor.`")
16001612
requireLibraryDeclared("`.ffiDtor.`")
@@ -1612,6 +1624,18 @@ macro ffiDtor*(args: varargs[untyped]): untyped =
16121624
let libParamName = formalParams[1][0]
16131625
let libTypeName = formalParams[1][1]
16141626

1627+
# A dtor is sync (no return type) or async (`Future[void]`); reject anything
1628+
# else up front rather than emitting an obscure downstream error.
1629+
let retTypeNode = formalParams[0]
1630+
let retIsFutureVoid =
1631+
retTypeNode.kind == nnkBracketExpr and $retTypeNode[0] == "Future" and
1632+
retTypeNode.len == 2 and $retTypeNode[1] == "void"
1633+
if retTypeNode.kind != nnkEmpty and not retIsFutureVoid:
1634+
error(
1635+
"ffiDtor: proc must return nothing (sync) or Future[void] (async), got: " &
1636+
retTypeNode.repr
1637+
)
1638+
16151639
let procNameStr = block:
16161640
let raw = $procName
16171641
if raw.endsWith("*"):
@@ -1639,16 +1663,26 @@ macro ffiDtor*(args: varargs[untyped]): untyped =
16391663
if ctx.isNil or cast[ptr FFIContext[`libTypeName`]](ctx)[].myLib.isNil:
16401664
return RET_ERR
16411665

1642-
ffiBody.add quote do:
1643-
let `libParamName` = cast[ptr FFIContext[`libTypeName`]](ctx)[].myLib[]
1644-
16451666
let isNoop =
16461667
bodyNode.kind == nnkEmpty or (
16471668
bodyNode.kind == nnkStmtList and bodyNode.len == 1 and
16481669
bodyNode[0].kind == nnkDiscardStmt
16491670
)
1650-
if not isNoop:
1651-
ffiBody.add(bodyNode)
1671+
1672+
# Lift the body into an async impl registered in the per-library
1673+
# `ffiTeardownHook`, which the FFI thread awaits at shutdown (see ffi_thread.nim
1674+
# and ffiTeardownHook's docstring). The C wrapper no longer runs the body.
1675+
let teardownImplName = genSym(nskProc, "ffiTeardownImpl")
1676+
let teardownRegistration =
1677+
if isNoop:
1678+
newEmptyNode()
1679+
else:
1680+
quote:
1681+
proc `teardownImplName`(lib: ptr `libTypeName`): Future[void] {.async.} =
1682+
let `libParamName` = lib[]
1683+
`bodyNode`
1684+
1685+
ffiTeardownHook[`libTypeName`]() = `teardownImplName`
16521686

16531687
let poolIdent = ident($libTypeName & "FFIPool")
16541688
ffiBody.add quote do:
@@ -1690,7 +1724,7 @@ macro ffiDtor*(args: varargs[untyped]): untyped =
16901724
when not declared(`poolIdent`):
16911725
var `poolIdent`: FFIContextPool[`libTypeName`]
16921726

1693-
let stmts = newStmtList(poolDecl, ffiProc)
1727+
let stmts = newStmtList(teardownRegistration, poolDecl, ffiProc)
16941728

16951729
when defined(ffiDumpMacros):
16961730
echo stmts.repr

tests/unit/test_ffi_teardown.nim

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
import std/[atomics, os]
2+
import unittest2
3+
import results
4+
import ffi
5+
6+
# Exercises the async `{.ffiDtor.}` teardown hook: a non-empty dtor body is
7+
# lifted into an `ffiTeardown` overload the FFI thread awaits on its own event
8+
# loop right before it exits. `destroyFFIContext` must block until that
9+
# teardown finishes.
10+
11+
type TeardownLib = object
12+
13+
# declareLibrary emits an importc `lib<name>NimMain`; this test links as a plain
14+
# exe, so stub it (mirrors test_ffi_context.nim).
15+
{.emit: "void libteardownlibNimMain(void) {}".}
16+
17+
declareLibrary("teardownlib", TeardownLib)
18+
19+
var gTeardownRan: Atomic[bool]
20+
var gTeardownThreadId: Atomic[int]
21+
22+
type NoopConfig {.ffi.} = object
23+
dummy: int
24+
25+
proc teardownlib_create*(
26+
config: NoopConfig
27+
): Future[Result[TeardownLib, string]] {.ffiCtor.} =
28+
return ok(TeardownLib())
29+
30+
proc teardownlib_destroy*(lib: TeardownLib): Future[void] {.ffiDtor.} =
31+
## Async teardown: sleeps on the FFI event loop, then records that it ran and
32+
## on which thread. If destroy didn't wait, `gTeardownRan` would still be false
33+
## when the test checks it.
34+
await sleepAsync(200.milliseconds)
35+
gTeardownThreadId.store(getThreadId())
36+
gTeardownRan.store(true)
37+
38+
proc noopCallback(
39+
retCode: cint, msg: ptr cchar, len: csize_t, userData: pointer
40+
) {.cdecl, gcsafe, raises: [].} =
41+
discard
42+
43+
proc encodedPtr(bytes: var seq[byte]): ptr byte =
44+
if bytes.len == 0:
45+
nil
46+
else:
47+
cast[ptr byte](addr bytes[0])
48+
49+
proc createCtxWithLib(): ptr FFIContext[TeardownLib] =
50+
## Spins up a context via the ctor and waits until `myLib` is populated (set on
51+
## the FFI thread when the ctor request is dispatched), so teardown has a lib.
52+
var cfg = cborEncode(TeardownlibCreateCtorReq(config: NoopConfig(dummy: 0)))
53+
let ret = teardownlib_create(encodedPtr(cfg), cfg.len.csize_t, noopCallback, nil)
54+
if ret.isNil():
55+
return nil
56+
let ctx = cast[ptr FFIContext[TeardownLib]](ret)
57+
var tries = 0
58+
while ctx[].myLib.isNil() and tries < 500:
59+
os.sleep(5)
60+
inc tries
61+
ctx
62+
63+
suite "async {.ffiDtor.} teardown hook":
64+
test "destroy blocks until the async teardown body completes":
65+
let ctx = createCtxWithLib()
66+
check not ctx.isNil()
67+
check not ctx[].myLib.isNil()
68+
69+
gTeardownRan.store(false)
70+
gTeardownThreadId.store(0)
71+
let callerTid = getThreadId()
72+
73+
check TeardownlibFFIPool.destroyFFIContext(ctx).isOk()
74+
75+
# If destroy returned before awaiting the teardown, this would be false.
76+
check gTeardownRan.load()
77+
# And it must have run on the FFI thread, not the caller's.
78+
check gTeardownThreadId.load() != 0
79+
check gTeardownThreadId.load() != callerTid
80+
81+
test "teardown runs exactly once per context":
82+
gTeardownRan.store(false)
83+
let ctx = createCtxWithLib()
84+
check not ctx.isNil()
85+
check TeardownlibFFIPool.destroyFFIContext(ctx).isOk()
86+
check gTeardownRan.load()

0 commit comments

Comments
 (0)