@@ -24,6 +24,12 @@ type FFIContext*[T] = object
2424 reqSignal: ThreadSignalPtr # to notify the FFI Thread that a new request is sent
2525 reqReceivedSignal: ThreadSignalPtr
2626 # to signal main thread, interfacing with the FFI thread, that FFI thread received the request
27+ stopSignal: ThreadSignalPtr
28+ # fired by destroyFFIContext so both ffiThread and watchdogThread can exit promptly
29+ threadExitSignal: ThreadSignalPtr
30+ # fired by ffiThread just before it exits; destroyFFIContext waits on
31+ # this with a bounded timeout instead of joining unconditionally, so a
32+ # blocked event loop cannot hang the caller forever
2733 userData* : pointer
2834 callbackState* : FFICallbackState
2935 running: Atomic [bool ] # To control when the threads are running
@@ -140,11 +146,14 @@ proc watchdogThreadBody(ctx: ptr FFIContext) {.thread.} =
140146 const WatchdogTimeout = 20 .seconds
141147
142148 # Give time for the node to be created and up before sending watchdog requests
143- await sleepAsync (WatchdogStartDelay )
149+ let initialStop = await ctx.stopSignal.wait ().withTimeout (WatchdogStartDelay )
150+ if initialStop or ctx.running.load == false :
151+ return
152+
144153 while true :
145- await sleepAsync (WatchdogTimeinterval )
154+ let intervalStop = await ctx.stopSignal. wait (). withTimeout (WatchdogTimeinterval )
146155
147- if ctx.running.load == false :
156+ if intervalStop or ctx.running.load == false :
148157 debug " Watchdog thread exiting because FFIContext is not running"
149158 break
150159
@@ -209,22 +218,27 @@ proc ffiThreadBody[T](ctx: ptr FFIContext[T]) {.thread.} =
209218
210219 logging.setupLog (logging.LogLevel .DEBUG , logging.LogFormat .TEXT )
211220
221+ defer :
222+ # Signal destroyFFIContext that this thread has exited, so its bounded
223+ # wait can unblock and proceed with cleanup.
224+ let fireRes = ctx.threadExitSignal.fireSync ()
225+ if fireRes.isErr ():
226+ error " failed to fire threadExitSignal on FFI thread exit" ,
227+ err = fireRes.error
228+
212229 let ffiRun = proc (ctx: ptr FFIContext [T]) {.async .} =
213230 var ffiReqHandler: T
214231 # # Holds the main library object, i.e., in charge of handling the ffi requests.
215232 # # e.g., Waku, LibP2P, SDS, etc.
216233
217- while true :
218- await ctx.reqSignal.wait ()
219-
220- if ctx.running.load == false :
221- break
234+ while ctx.running.load ():
235+ let gotSignal = await ctx.reqSignal.wait ().withTimeout (100 .milliseconds)
236+ if not gotSignal:
237+ continue
222238
223239 # # Wait for a request from the ffi consumer thread
224240 var request: ptr FFIThreadRequest
225- let recvOk = ctx.reqChannel.tryRecv (request)
226- if not recvOk:
227- chronicles.error " ffi thread could not receive a request"
241+ if not ctx.reqChannel.tryRecv (request):
228242 continue
229243
230244 if ctx.myLib.isNil ():
@@ -243,10 +257,36 @@ proc cleanUpResources[T](ctx: ptr FFIContext[T]): Result[void, string] =
243257 defer :
244258 freeShared (ctx)
245259 ctx.lock.deinitLock ()
246- if not ctx.reqSignal.isNil ():
247- ? ctx.reqSignal.close ()
248- if not ctx.reqReceivedSignal.isNil ():
249- ? ctx.reqReceivedSignal.close ()
260+ when defined (gcRefc):
261+ # # ThreadSignalPtr.close() is intentionally skipped under --mm:refc.
262+ # #
263+ # # close() goes through chronos's safeUnregisterAndCloseFd, which calls
264+ # # getThreadDispatcher() and lazily allocates a new Selector for the
265+ # # main thread. With refc and a heavy ref-object graph torn down by the
266+ # # FFI thread (libwaku/libp2p), that allocation traps inside rawNewObj
267+ # # and the refc signal handler re-enters the same allocator — the
268+ # # process never returns. Captured stack from a hung process:
269+ # # close → safeUnregisterAndCloseFd → getThreadDispatcher →
270+ # # newDispatcher → Selector.new → newObj (gc.nim:488) →
271+ # # rawNewObj (gc.nim:470) → rawNewObj → _sigtramp → signalHandler →
272+ # # newObjNoInit → addNewObjToZCT (infinite re-entry)
273+ # #
274+ # # --mm:orc does NOT exhibit this bug; see the
275+ # # "destroyFFIContext refc workaround" suite in tests/test_ffi_context.nim
276+ # # (test "destroy after heavy ref-allocation workload returns promptly").
277+ # # The signal fds (a few per ctx) are reclaimed by the OS at process
278+ # # exit; destroyFFIContext is called once per process lifetime, so the
279+ # # leak is bounded.
280+ discard
281+ else :
282+ if not ctx.reqSignal.isNil ():
283+ ? ctx.reqSignal.close ()
284+ if not ctx.reqReceivedSignal.isNil ():
285+ ? ctx.reqReceivedSignal.close ()
286+ if not ctx.stopSignal.isNil ():
287+ ? ctx.stopSignal.close ()
288+ if not ctx.threadExitSignal.isNil ():
289+ ? ctx.threadExitSignal.close ()
250290 return ok ()
251291
252292proc createFFIContext * [T](): Result [ptr FFIContext [T], string ] =
@@ -255,16 +295,24 @@ proc createFFIContext*[T](): Result[ptr FFIContext[T], string] =
255295 var ctx = createShared (FFIContext [T], 1 )
256296 ctx.lock.initLock ()
257297
298+ var success = false
299+ defer :
300+ if not success:
301+ ctx.cleanUpResources ().isOkOr:
302+ error " failed to clean up resources after createFFIContext failure" ,
303+ err = error
304+
258305 ctx.reqSignal = ThreadSignalPtr .new ().valueOr:
259- ctx.cleanUpResources ().isOkOr:
260- return err (" could not clean resources in a failure new reqSignal: " & $ error)
261306 return err (" couldn't create reqSignal ThreadSignalPtr: " & $ error)
262307
263308 ctx.reqReceivedSignal = ThreadSignalPtr .new ().valueOr:
264- ctx.cleanUpResources ().isOkOr:
265- return
266- err (" could not clean resources in a failure new reqReceivedSignal: " & $ error)
267- return err (" couldn't create reqReceivedSignal ThreadSignalPtr" )
309+ return err (" couldn't create reqReceivedSignal ThreadSignalPtr: " & $ error)
310+
311+ ctx.stopSignal = ThreadSignalPtr .new ().valueOr:
312+ return err (" couldn't create stopSignal ThreadSignalPtr: " & $ error)
313+
314+ ctx.threadExitSignal = ThreadSignalPtr .new ().valueOr:
315+ return err (" couldn't create threadExitSignal ThreadSignalPtr: " & $ error)
268316
269317 ctx.registeredRequests = addr ffi_types.registeredRequests
270318
@@ -273,31 +321,33 @@ proc createFFIContext*[T](): Result[ptr FFIContext[T], string] =
273321 try :
274322 createThread (ctx.ffiThread, ffiThreadBody[T], ctx)
275323 except ValueError , ResourceExhaustedError :
276- ctx.cleanUpResources ().isOkOr:
277- error " failed to clean up resources after ffiThread creation failure" , err = error
278324 return err (" failed to create the FFI thread: " & getCurrentExceptionMsg ())
279325
280326 try :
281327 createThread (ctx.watchdogThread, watchdogThreadBody, ctx)
282328 except ValueError , ResourceExhaustedError :
329+ # # ffiThread is already running; signal it to exit and join before the
330+ # # deferred cleanUpResources closes the signals it's waiting on.
283331 ctx.running.store (false )
284332 let fireRes = ctx.reqSignal.fireSync ()
285333 if fireRes.isErr ():
286- error " failed to signal ffiThread during watchdog cleanup" , err = fireRes.error
334+ error " failed to signal ffiThread during watchdog cleanup" ,
335+ err = fireRes.error
287336 joinThread (ctx.ffiThread)
288- ctx.cleanUpResources ().isOkOr:
289- error " failed to clean up resources after watchdogThread creation failure" , err = error
290337 return err (" failed to create the watchdog thread: " & getCurrentExceptionMsg ())
291338
339+ success = true
292340 return ok (ctx)
293341
294342proc destroyFFIContext * [T](ctx: ptr FFIContext [T]): Result [void , string ] =
343+ # # If the FFI thread's event loop is blocked by a synchronous handler
344+ # # (e.g. blocking I/O), it cannot process reqSignal in time to exit.
345+ # # In that case we leak ctx and the thread rather than hanging forever:
346+ # # the thread will eventually exit on its own, but cleanup is skipped
347+ # # because the thread may still be touching ctx fields.
348+ const ThreadExitTimeout = 1500 .milliseconds
349+
295350 ctx.running.store (false )
296- defer :
297- joinThread (ctx.ffiThread)
298- joinThread (ctx.watchdogThread)
299- ctx.cleanUpResources ().isOkOr:
300- error " failed to clean up resources in destroyFFIContext" , err = error
301351
302352 let signaledOnTime = ctx.reqSignal.fireSync ().valueOr:
303353 ctx.onNotResponding ()
@@ -306,6 +356,27 @@ proc destroyFFIContext*[T](ctx: ptr FFIContext[T]): Result[void, string] =
306356 ctx.onNotResponding ()
307357 return err (" failed to signal reqSignal on time in destroyFFIContext" )
308358
359+ ctx.stopSignal.fireSync ().isOkOr:
360+ error " failed to fire stopSignal in destroyFFIContext" , err = $ error
361+
362+ # # Bounded wait for ffiThread to exit. waitSync blocks the calling thread
363+ # # up to the timeout; ffiThread fires threadExitSignal in its defer block.
364+ let exitedOnTime = ctx.threadExitSignal.waitSync (ThreadExitTimeout ).valueOr:
365+ ctx.onNotResponding ()
366+ return err (" error waiting for FFI thread exit: " & $ error)
367+
368+ if not exitedOnTime:
369+ # # Event loop is blocked by a synchronous handler. Leak the thread and
370+ # # ctx to avoid hanging the caller forever.
371+ ctx.onNotResponding ()
372+ return err (" FFI thread did not exit in time; leaking ctx to avoid hang" )
373+
374+ joinThread (ctx.ffiThread)
375+ joinThread (ctx.watchdogThread)
376+ ctx.cleanUpResources ().isOkOr:
377+ error " failed to clean up resources in destroyFFIContext" , err = error
378+ return err (" cleanUpResources failed: " & $ error)
379+
309380 return ok ()
310381
311382template checkParams * (ctx: ptr FFIContext , callback: FFICallBack , userData: pointer ) =
0 commit comments