Skip to content

Commit 6d31fa3

Browse files
use fixed array of ctx to avoid consuming all fds (#14)
1 parent 81c62c2 commit 6d31fa3

9 files changed

Lines changed: 319 additions & 159 deletions

File tree

examples/nim_timer/nim_timer.nim

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,7 @@ proc nimtimerComplex*(
7272
# In a multi-file library, import all sub-modules first and call genBindings()
7373
# once, at the bottom of the top-level compilation-root file.
7474
# This call is a no-op unless -d:ffiGenBindings is passed to the compiler.
75-
genBindings() # reads -d:ffiOutputDir, -d:ffiNimSrcRelPath, -d:targetLang from compile flags
75+
genBindings()
7676

7777
proc nimtimer_destroy*(ctx: pointer) {.dynlib, exportc, cdecl, raises: [].} =
7878
## Tears down the FFI context created by nimtimer_create.

ffi.nim

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,10 @@ import std/[atomics, tables]
22
import chronos, chronicles
33
import
44
ffi/internal/[ffi_library, ffi_macro],
5-
ffi/[alloc, ffi_types, ffi_context, ffi_thread_request, serial]
5+
ffi/[alloc, ffi_types, ffi_context, ffi_context_pool, ffi_thread_request, serial]
66

77
export atomics, tables
88
export chronos, chronicles
99
export
10-
atomics, alloc, ffi_library, ffi_macro, ffi_types, ffi_context, ffi_thread_request, serial
10+
atomics, alloc, ffi_library, ffi_macro, ffi_types, ffi_context, ffi_context_pool,
11+
ffi_thread_request, serial

ffi/ffi_context.nim

Lines changed: 53 additions & 76 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
{.pragma: callback, cdecl, raises: [], gcsafe.}
33
{.passc: "-fPIC".}
44

5-
import std/[options, atomics, os, net, locks, json, tables, sets]
5+
import std/[atomics, locks, json, tables]
66
import chronicles, chronos, chronos/threadsync, taskpools/channels_spsc_single, results
77
import ./ffi_types, ./ffi_thread_request, ./internal/ffi_macro, ./logging
88

@@ -41,30 +41,6 @@ var ffiCurrentCallbackState* {.threadvar.}: ptr FFICallbackState
4141

4242
const git_version* {.strdefine.} = "n/a"
4343

44-
var contextRegistry = initHashSet[pointer]()
45-
var contextRegistryLock: Lock
46-
contextRegistryLock.initLock()
47-
48-
proc registerCtx(ctx: pointer) =
49-
{.cast(gcsafe).}:
50-
contextRegistryLock.acquire()
51-
defer: contextRegistryLock.release()
52-
contextRegistry.incl(ctx)
53-
54-
proc unregisterCtx(ctx: pointer) =
55-
{.cast(gcsafe).}:
56-
contextRegistryLock.acquire()
57-
defer: contextRegistryLock.release()
58-
contextRegistry.excl(ctx)
59-
60-
proc isValidCtx*(ctx: pointer): bool =
61-
## Returns true only if ctx was created by createFFIContext and not yet destroyed.
62-
## Rejects nil, offset-invalid, and dangling pointers at the API boundary.
63-
{.cast(gcsafe).}:
64-
contextRegistryLock.acquire()
65-
defer: contextRegistryLock.release()
66-
return contextRegistry.contains(ctx)
67-
6844
template callEventCallback*(ctx: ptr FFIContext, eventName: string, body: untyped) =
6945
if isNil(ctx[].callbackState.callback):
7046
chronicles.error eventName & " - eventCallback is nil"
@@ -74,14 +50,20 @@ template callEventCallback*(ctx: ptr FFIContext, eventName: string, body: untype
7450
try:
7551
let event = body
7652
cast[FFICallBack](ctx[].callbackState.callback)(
77-
RET_OK, unsafeAddr event[0], cast[csize_t](len(event)), ctx[].callbackState.userData
53+
RET_OK,
54+
unsafeAddr event[0],
55+
cast[csize_t](len(event)),
56+
ctx[].callbackState.userData,
7857
)
7958
except Exception, CatchableError:
8059
let msg =
8160
"Exception " & eventName & " when calling 'eventCallBack': " &
8261
getCurrentExceptionMsg()
8362
cast[FFICallBack](ctx[].callbackState.callback)(
84-
RET_ERR, unsafeAddr msg[0], cast[csize_t](len(msg)), ctx[].callbackState.userData
63+
RET_ERR,
64+
unsafeAddr msg[0],
65+
cast[csize_t](len(msg)),
66+
ctx[].callbackState.userData,
8567
)
8668

8769
template dispatchFfiEvent*(eventName: string, body: untyped) =
@@ -99,18 +81,14 @@ template dispatchFfiEvent*(eventName: string, body: untyped) =
9981
RET_OK, unsafeAddr event[0], cast[csize_t](len(event)), ffiState[].userData
10082
)
10183
except Exception, CatchableError:
102-
let msg =
103-
"Exception dispatching " & eventName & ": " & getCurrentExceptionMsg()
84+
let msg = "Exception dispatching " & eventName & ": " & getCurrentExceptionMsg()
10485
cast[FFICallBack](ffiState[].callback)(
10586
RET_ERR, unsafeAddr msg[0], cast[csize_t](len(msg)), ffiState[].userData
10687
)
10788

10889
proc sendRequestToFFIThread*(
10990
ctx: ptr FFIContext, ffiRequest: ptr FFIThreadRequest, timeout = InfiniteDuration
11091
): Result[void, string] =
111-
if not isValidCtx(cast[pointer](ctx)):
112-
deleteRequest(ffiRequest)
113-
return err("ctx is not a valid FFI context")
11492
ctx.lock.acquire()
11593
# This lock is only necessary while we use a SP Channel and while the signalling
11694
# between threads assumes that there aren't concurrent requests.
@@ -229,15 +207,17 @@ proc processRequest[T](
229207
try:
230208
await retFut
231209
except AsyncError as exc:
232-
Result[string, string].err("Async error in processRequest for " & reqId & ": " & exc.msg)
210+
Result[string, string].err(
211+
"Async error in processRequest for " & reqId & ": " & exc.msg
212+
)
233213

234214
## handleRes may raise (OOM, GC setup) even though it is rare. Catching here
235215
## keeps the async proc raises:[] compatible. The defer inside handleRes
236216
## guarantees request is freed before the exception propagates.
237217
try:
238218
handleRes(res, request)
239219
except Exception as exc:
240-
error "Unexpected exception in handleRes", exc = exc.msg
220+
error "Unexpected exception in handleRes", error = exc.msg
241221

242222
proc ffiThreadBody[T](ctx: ptr FFIContext[T]) {.thread.} =
243223
## FFI thread body that attends library user API requests
@@ -250,8 +230,7 @@ proc ffiThreadBody[T](ctx: ptr FFIContext[T]) {.thread.} =
250230
# wait can unblock and proceed with cleanup.
251231
let fireRes = ctx.threadExitSignal.fireSync()
252232
if fireRes.isErr():
253-
error "failed to fire threadExitSignal on FFI thread exit",
254-
err = fireRes.error
233+
error "failed to fire threadExitSignal on FFI thread exit", err = fireRes.error
255234

256235
let ffiRun = proc(ctx: ptr FFIContext[T]) {.async.} =
257236
var ffiReqHandler: T
@@ -281,6 +260,7 @@ proc ffiThreadBody[T](ctx: ptr FFIContext[T]) {.thread.} =
281260
waitFor ffiRun(ctx)
282261

283262
proc cleanUpResources[T](ctx: ptr FFIContext[T]): Result[void, string] =
263+
## Full cleanup for heap-allocated contexts: closes all resources and frees memory.
284264
defer:
285265
freeShared(ctx)
286266
ctx.lock.deinitLock()
@@ -316,18 +296,18 @@ proc cleanUpResources[T](ctx: ptr FFIContext[T]): Result[void, string] =
316296
?ctx.threadExitSignal.close()
317297
return ok()
318298

319-
proc createFFIContext*[T](): Result[ptr FFIContext[T], string] =
320-
## This proc is called from the main thread and it creates
321-
## the FFI working thread.
322-
var ctx = createShared(FFIContext[T], 1)
299+
proc initContextResources*[T](ctx: ptr FFIContext[T]): Result[void, string] =
300+
## Initialises all resources inside an already-allocated FFIContext slot.
301+
## On failure every partially-initialised resource is closed; the caller
302+
## is responsible for releasing the slot (freeShared or pool.releaseSlot).
323303
ctx.lock.initLock()
324304

325305
var success = false
326306
defer:
327307
if not success:
328308
ctx.cleanUpResources().isOkOr:
329309
error "failed to clean up resources after createFFIContext failure",
330-
err = error
310+
error = error
331311

332312
ctx.reqSignal = ThreadSignalPtr.new().valueOr:
333313
return err("couldn't create reqSignal ThreadSignalPtr: " & $error)
@@ -358,61 +338,58 @@ proc createFFIContext*[T](): Result[ptr FFIContext[T], string] =
358338
ctx.running.store(false)
359339
let fireRes = ctx.reqSignal.fireSync()
360340
if fireRes.isErr():
361-
error "failed to signal ffiThread during watchdog cleanup",
362-
err = fireRes.error
341+
error "failed to signal ffiThread during watchdog cleanup", error = fireRes.error
363342
joinThread(ctx.ffiThread)
364343
return err("failed to create the watchdog thread: " & getCurrentExceptionMsg())
365344

366-
registerCtx(cast[pointer](ctx))
367345
success = true
368-
return ok(ctx)
369-
370-
proc destroyFFIContext*[T](ctx: ptr FFIContext[T]): Result[void, string] =
371-
## If the FFI thread's event loop is blocked by a synchronous handler
372-
## (e.g. blocking I/O), it cannot process reqSignal in time to exit.
373-
## In that case we leak ctx and the thread rather than hanging forever:
374-
## the thread will eventually exit on its own, but cleanup is skipped
375-
## because the thread may still be touching ctx fields.
376-
const ThreadExitTimeout = 1500.milliseconds
377-
unregisterCtx(cast[pointer](ctx))
346+
return ok()
378347

348+
proc signalStop*[T](ctx: ptr FFIContext[T]): Result[void, string] =
379349
ctx.running.store(false)
380-
381-
let signaledOnTime = ctx.reqSignal.fireSync().valueOr:
350+
let reqSignaled = ctx.reqSignal.fireSync().valueOr:
382351
ctx.onNotResponding()
383-
return err("error in destroyFFIContext: " & $error)
384-
if not signaledOnTime:
352+
return err("error signaling reqSignal in signalStop: " & $error)
353+
if not reqSignaled:
385354
ctx.onNotResponding()
386-
return err("failed to signal reqSignal on time in destroyFFIContext")
355+
return err("failed to signal reqSignal on time in signalStop")
356+
let stopSignaled = ctx.stopSignal.fireSync().valueOr:
357+
return err("error signaling stopSignal in signalStop: " & $error)
358+
if not stopSignaled:
359+
return err("failed to signal stopSignal on time in signalStop")
360+
return ok()
387361

388-
ctx.stopSignal.fireSync().isOkOr:
389-
error "failed to fire stopSignal in destroyFFIContext", err = $error
362+
## If the FFI thread's event loop is blocked by a synchronous handler
363+
## (e.g. blocking I/O), it cannot process reqSignal in time to exit.
364+
## clearContext waits on threadExitSignal up to this bound; on timeout it
365+
## returns err and skips joinThread/cleanup (leaking the thread + ctx slot)
366+
## rather than hanging the caller forever.
367+
const ThreadExitTimeout* = 1500.milliseconds
368+
369+
proc stopAndJoinThreads*[T](ctx: ptr FFIContext[T]): Result[void, string] =
370+
## Signals the FFI and watchdog threads to stop, waits up to ThreadExitTimeout
371+
## for the FFI thread to exit, and joins both. On timeout returns err and
372+
## skips joinThread (leaving the threads live) rather than hanging the caller.
373+
## Resource cleanup (signal fds, lock) is the caller's responsibility.
374+
ctx.signalStop().isOkOr:
375+
return err("signalStop failed: " & $error)
390376

391-
## Bounded wait for ffiThread to exit. waitSync blocks the calling thread
392-
## up to the timeout; ffiThread fires threadExitSignal in its defer block.
393377
let exitedOnTime = ctx.threadExitSignal.waitSync(ThreadExitTimeout).valueOr:
394378
ctx.onNotResponding()
395379
return err("error waiting for FFI thread exit: " & $error)
396380

397381
if not exitedOnTime:
398-
## Event loop is blocked by a synchronous handler. Leak the thread and
399-
## ctx to avoid hanging the caller forever.
400382
ctx.onNotResponding()
401383
return err("FFI thread did not exit in time; leaking ctx to avoid hang")
402384

403385
joinThread(ctx.ffiThread)
404386
joinThread(ctx.watchdogThread)
387+
return ok()
388+
389+
proc clearContext[T](ctx: ptr FFIContext[T]): Result[void, string] =
390+
## Stops the FFI context that was created via createFFIContext[T]() (heap).
391+
ctx.stopAndJoinThreads().isOkOr:
392+
return err("clearContext: " & $error)
405393
ctx.cleanUpResources().isOkOr:
406-
error "failed to clean up resources in destroyFFIContext", err = error
407394
return err("cleanUpResources failed: " & $error)
408-
409395
return ok()
410-
411-
template checkParams*(ctx: ptr FFIContext, callback: FFICallBack, userData: pointer) =
412-
if not isValidCtx(cast[pointer](ctx)):
413-
return RET_ERR
414-
415-
ctx[].userData = userData
416-
417-
if isNil(callback):
418-
return RET_MISSING_CALLBACK

ffi/ffi_context_pool.nim

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import std/atomics
2+
import results
3+
import ./ffi_context
4+
5+
const MaxFFIContexts* = 32
6+
## Maximum number of concurrently live FFI contexts when using FFIContextPool.
7+
## Fds and threads are only consumed for slots that are actually acquired,
8+
## so this value only affects the upfront memory of the pool array.
9+
10+
type FFIContextPool*[T] = object
11+
## Fixed-size pool of FFI contexts. Avoids dynamic heap allocation per context
12+
## and bounds the total number of file descriptors consumed by ThreadSignalPtrs
13+
## to at most MaxFFIContexts * 2.
14+
slots: array[MaxFFIContexts, FFIContext[T]]
15+
inUse: array[MaxFFIContexts, Atomic[bool]]
16+
17+
proc acquireSlot[T](pool: var FFIContextPool[T]): Result[ptr FFIContext[T], string] =
18+
for i in 0 ..< MaxFFIContexts:
19+
var expected = false
20+
if pool.inUse[i].compareExchange(expected, true):
21+
return ok(pool.slots[i].addr)
22+
return err("FFI context pool exhausted (max " & $MaxFFIContexts & " contexts)")
23+
24+
proc releaseSlot[T](pool: var FFIContextPool[T], ctx: ptr FFIContext[T]) =
25+
for i in 0 ..< MaxFFIContexts:
26+
if pool.slots[i].addr == ctx:
27+
pool.inUse[i].store(false)
28+
return
29+
30+
proc createFFIContext*[T](
31+
pool: var FFIContextPool[T]
32+
): Result[ptr FFIContext[T], string] =
33+
## Acquires a slot from the fixed pool and initialises it as an FFI context.
34+
## Bounded fd usage: at most MaxFFIContexts * 2 ThreadSignalPtr fds are ever open.
35+
let ctx = pool.acquireSlot().valueOr:
36+
return err("createFFIContext: acquireSlot failed: " & $error)
37+
initContextResources(ctx).isOkOr:
38+
pool.releaseSlot(ctx)
39+
return err("createFFIContext: initContextResources failed: " & $error)
40+
return ok(ctx)
41+
42+
proc destroyFFIContext*[T](
43+
pool: var FFIContextPool[T], ctx: ptr FFIContext[T]
44+
): Result[void, string] =
45+
## Stops the FFI context and returns its slot to the pool. If the FFI thread
46+
## is blocked and does not exit in time, the slot is leaked rather than
47+
## reclaimed — closing its resources while the thread is still live would be
48+
## unsafe.
49+
ctx.stopAndJoinThreads().isOkOr:
50+
return err("destroyFFIContext(pool): " & $error)
51+
pool.releaseSlot(ctx)
52+
return ok()
53+
54+
proc isValidCtx*[T](pool: var FFIContextPool[T], ctx: pointer): bool =
55+
## Returns true only if ctx points to one of the pool's slots that is
56+
## currently in use. Rejects nil, offset-invalid, and dangling pointers
57+
## at the API boundary, preventing use-after-free dereferences.
58+
if ctx.isNil():
59+
return false
60+
for i in 0 ..< MaxFFIContexts:
61+
if cast[pointer](pool.slots[i].addr) == ctx:
62+
return pool.inUse[i].load()
63+
return false

0 commit comments

Comments
 (0)