Skip to content

Commit c5648ec

Browse files
better onDestroy sync
1 parent c7e0344 commit c5648ec

3 files changed

Lines changed: 270 additions & 37 deletions

File tree

ffi/ffi_context.nim

Lines changed: 102 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -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

252292
proc 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

294342
proc 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

311382
template checkParams*(ctx: ptr FFIContext, callback: FFICallBack, userData: pointer) =

ffi/internal/ffi_macro.nim

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -958,11 +958,13 @@ macro ffi*(prc: untyped): untyped =
958958
let `retValOrErrIdent` = `syncHelperCall`
959959
if `retValOrErrIdent`.isErr():
960960
let errStr = `retValOrErrIdent`.error
961-
callback(RET_ERR, unsafeAddr errStr[0], cast[csize_t](errStr.len), userData)
961+
callback(
962+
RET_ERR, cast[ptr cchar](errStr.cstring), cast[csize_t](errStr.len), userData
963+
)
962964
return RET_ERR
963965
let serialized = ffiSerialize(`retValOrErrIdent`.value)
964966
callback(
965-
RET_OK, unsafeAddr serialized[0], cast[csize_t](serialized.len), userData
967+
RET_OK, cast[ptr cchar](serialized.cstring), cast[csize_t](serialized.len), userData
966968
)
967969
return RET_OK
968970

0 commit comments

Comments
 (0)