If the wrapper just calls the event loop scheduler and polls then runs other events until the called proc completes you could end up with recursively nested event loops if there are function calls that alternate between passive and non-passive procs.
That is pretty much exactly what will happen. But IMHO it works beautifully:
active main() ← hardware stack, thread-local
complete(readLine()) ← hardware stack loop on same thread
readLine {.passive.} ← stack-allocated coroVar
ioWait → suspend() ← worker freed to pool
... other tasks run ...
resume on SAME thread ← coroVar pointer still valid
complete() returns
use result
complete() may nest when active and passive alternate, but each level is just a shallow loop — coroutine state lives in CPS frames, not on the hardware stack. While one call suspends, the worker keeps doing other pool work; from main's perspective it's still a plain blocking call.
The stack frame from the active caller pins resume to the same thread, which is exactly what makes this safe. The main footgun is {.threadvar.} inside {.passive.} — that's task semantics on thread-local storage, and the compiler could reject it statically.
here is alternaring example that grows stack size
import std/syncio
var RLIMIT_STACK* {.importc: "RLIMIT_STACK", header: "<sys/resource.h>".}: cint
type rlim_t {.importc: "rlim_t", header: "<sys/resource.h>".} = cint
type Rlimit {.importc: "struct rlimit", header: "<sys/resource.h>".} = object
rlim_cur: rlim_t
rlim_max: rlim_t
proc getrlimit(resource: cint, outptr: ptr Rlimit) {.importc: "getrlimit", header: "<sys/resource.h>".}
proc setrlimit(resource: cint, outptr: ptr Rlimit) {.importc: "setrlimit", header: "<sys/resource.h>".}
var rlimit = Rlimit(rlim_cur: 1024*128.cint, rlim_max: 1024*1024.cint)
setrlimit(RLIMIT_STACK, rlimit.addr)
getrlimit(RLIMIT_STACK, rlimit.addr)
echo "CURRENT RLIMIT_STACK ", rlimit.rlim_cur, " ", rlimit.rlim_max
var q = 0
proc a(b: int) {.passive.} =
if b > 0:
a(b-1)
q += 1
a(10000)
echo q
q = 0
proc alternate2(b: int) =
if b > 0:
alternate(b-1)
q += 1
proc alternate(b: int) {.passive.} =
if b > 0:
alternate2(b-1)
q += 1
alternate(10000)
echo q or even better
nim
var queue = newSeq[Continuation]()
proc scheduler(c: Continuation): Continuation =
result = c.fn(c.env)
if result.fn == nil and queue.len > 0:
result = queue.pop()
setScheduler(scheduler)
proc ioWait() {.passive.} =
suspend()
proc nonpassive() =
ioWait()
proc task() {.passive.} =
nonpassive()
for i in 0..10000:
queue.add delay task()
let c = queue.pop()
c.complete() ok, now i see, scheduler is not a proper tool for queuing.
Thanks for your report anyway, it makes for a great addition to the documentation!
Concurrent requests shouldn't nest on one hardware stack. Each connection is spawned as its own continuation and submitted to the pool — new requests go in the run queue, not inside an unreturned complete(). Stack depth is bounded per handler call chain, not per number of live connections. The queue-in-scheduler antipattern is real, but the fix isn't a fancier nested scheduler — it's enqueue at pool boundaries (which the thread pool already does). Interactive domains don't need a finite task set; they need to not model concurrency as nested blocking calls on one thread.
Assembly via stack manipulation trades the "where do I come from data" against the "what do I need in the future" -- that is worse for memory consumption.