fix(ffi): run the {.ffiDtor.} teardown on the recycle path - #147
Merged
Conversation
gmelodie
force-pushed
the
fix/avoid-zombie-node-on-destroy
branch
3 times, most recently
from
August 6, 2026 13:07
db82a01 to
aa29e68
Compare
gmelodie
force-pushed
the
fix/avoid-zombie-node-on-destroy
branch
from
August 6, 2026 13:36
aa29e68 to
8e394b9
Compare
gmelodie
requested review from
Ivansete-status and
NagyZoltanPeter
and removed request for
Ivansete-status
August 6, 2026 15:11
gmelodie
marked this pull request as ready for review
August 6, 2026 15:11
NagyZoltanPeter
approved these changes
Aug 6, 2026
NagyZoltanPeter
left a comment
Collaborator
There was a problem hiding this comment.
I find it good and covers what we need.
Would it be possible to put it into v0.3.0.... rc version. We need to add such into logos-delivery IMO.
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.
Summary
A
{.ffiDtor.}body never ran. The C destructor thatbuildFFIDtorProcemits callsrecycleFFIContext(ffi/internal/ffi_macro.nim:1696), but the only site that awaitedffiTeardownHookwas the thread-exit epilogue of the full-shutdown path, which needsctx.running == false. OnlydestroyFFIContextsets that, and no generated wrapper calls it. So the hook site sat on a path nobody takes, and every dtor body shipped by a consumer was dead code.The consequence is worse than a skipped callback.
recycleContextfrees the library withfreeLib, which is memory-only, and the pooled worker survives a recycle by design. Anything the library spawned itself keeps running on that dispatcher, invisible to a host whose handle is gone — and the nextcreateFFIContextcan hand the same slot, and the same event loop, to a different owner.recycleContextnow awaits the hook after it drains the in-flight requests and before it frees the library. Both call sites share onerunTeardownhelper so the recycle path and the destroy path cannot drift apart.Nothing in-tree caught this: both examples (
examples/echo/echo.nim:57,examples/timer/timer.nim:154) have no-op dtor bodies, andtests/unit/test_ffi_teardown.nimonly exerciseddestroyFFIContext.Affected Areas
ffi/ffi_thread.nim— newrunTeardown[T], called fromrecycleContextand from the thread-exit epilogue. The ordering is load-bearing: after the drain so no handler runs against a stopped library, beforefreeLibbecause the hook needsmyLib, beforeclearListenersso events the teardown emits still reach the host. It runs only oncelibReadyis set, becausemyLibotherwise points at the worker's zero-valued fallback, which is nil for areflibrary type.ffi/ffi_thread.nim—recycleContextnow firesrecycleDoneSignaland releases the slot from adefer. The function awaits arbitrary consumer code, and a raise out of that code must not strand the slot inRecycling.ffi/event_thread.nim— the heartbeat check is skipped unless the lifecycle isActive. The worker ticksproveAliveonly at the top of its loop, so a teardown parks the heartbeat for its whole duration; against a 1 sFFIHeartbeatStaleThresholdevery healthy shutdown longer than a second would emit aNotRespondingEvent, and the matchingonRespondingcould never arrive becauseclearListenersruns first. TheeventQueueStuckcheck is untouched.ffi/ffi_context.nim— newTeardownTimeoutMs(-d:ffiTeardownTimeoutMs, 10 s default) andRecycleWaitTimeoutwidened to cover it, so a synchronousrecycleFFIContextdoes not give up while a legitimate teardown runs.ffi/internal/ffi_macro.nim— the{.ffiDtor.}docstring now states where the body runs, how long the wrapper blocks its caller, and that the body must stay cancellable.tests/unit/test_ffi_teardown.nim— a recycle suite covering the hook onrecycleFFIContext, through the C-exported wrapper, on a slot proven to be the reused one, and past the timeout..gitignore—tests/unit/test_*(the rule for extensionless compiled binaries) also swallowedtests/unit/test_ffi_teardown.nim.cfg. Added a!tests/unit/test_*.nim.cfgescape hatch next to the existing!tests/unit/test_*.nim.Impact on Library Users
Behaviour change for any consumer that already ships a non-empty
{.ffiDtor.}body: it went from dead code to live shutdown code, and it now runs on the ordinary destroy path. Read that body again before you take this bump.The generated destructor now blocks its caller for as long as the teardown takes, up to
RecycleWaitTimeout— 15 s at the default budgets, against 5 s before. Do not call it from a thread that must stay responsive.Keep the dtor cancellable. At
TeardownTimeoutthe hook is cancelled, but chronoswithTimeoutthen waits for the cancellation to land, so a body that swallowsCancelledErrorstill holds the recycle. That is a slow-shutdown bug in the consumer, not something nim-ffi can bound from the outside.ThreadExitTimeoutMskeeps its 1500 ms default. Raise it pastffiTeardownTimeoutMsif your dtor is slow on thedestroyFFIContextpath.Risk Assessment
recycleContext, including a raise out of the dtor, because the release sits in adefer.TeardownTimeoutis a cancellation request, not a hard ceiling. A dtor that ignores cancellation blocks the recycle, the caller then fails onRecycleWaitTimeout, and that slot does not return to the pool. Documented rather than papered over.NotRespondingEvent.TeardownTimeoutandRecycleWaitTimeoutare the bounds that cover that window instead, and the alternative was a false alarm on every normal shutdown.ThreadExitTimeoutwas deliberately left alone:tests/unit/test_ffi_context.nim:261asserts thatdestroyFFIContextgives up in under 3 s when the worker loop is wedged, and raising the default breaks that fast-bail guarantee.libReadygate itself. Proving it needs a second library type with a failing constructor, and the reasoning rests onffi_codegen_common.nim:27, which guards{.ffi.}calls the same way.References
logosdelivery_destroywithoutstop_nodeperforms no node shutdown. Part 2 of the proposed fix is this change.STOP_NODEbefore the recycle. It stays correct until liblogosdelivery can register a{.ffiDtor.}that awaitsLogosDelivery.stopand collapse that hand-written destroy body back to a plain recycle.This is necessary but not sufficient for #4108. nim-ffi cannot cancel work a library spawned on its own, so liblogosdelivery must still register the dtor.