Skip to content

Commit f5dbbbd

Browse files
committed
disallow reentrancy
Reentrancy causes event reordering and stack explosions, in addition to leading to hard-to-debug scenarios. Since none of chronos is actually tested under reentrant conditions (ie that all features such as exceptions, cancellations, buffer operations, timers etc work reliably when the loop is reentered), this is hard to support over time and prevents useful optimizations - this PR simply detects and disallows the behaviour to remove the uncertainty, simplifying reasoning about the event loop in general.
1 parent 8156e29 commit f5dbbbd

File tree

3 files changed

+33
-75
lines changed

3 files changed

+33
-75
lines changed

chronos/internal/asyncengine.nim

Lines changed: 25 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -60,20 +60,23 @@ type
6060
ticks*: Deque[AsyncCallback]
6161
trackers*: Table[string, TrackerBase]
6262
counters*: Table[string, TrackerCounter]
63-
64-
proc sentinelCallbackImpl(arg: pointer) {.gcsafe, noreturn.} =
65-
raiseAssert "Sentinel callback MUST not be scheduled"
66-
67-
const
68-
SentinelCallback = AsyncCallback(function: sentinelCallbackImpl,
69-
udata: nil)
70-
71-
proc isSentinel(acb: AsyncCallback): bool =
72-
acb == SentinelCallback
63+
polling*: bool
64+
## The event loop is currently running
7365

7466
proc `<`(a, b: TimerCallback): bool =
7567
result = a.finishAt < b.finishAt
7668

69+
template preparePoll(loop: PDispatcherBase) =
70+
# If you hit this assert, you've called `poll`, `runForever` or `waitFor`
71+
# from within an async function which is not supported due to the difficulty
72+
# to control stack depth and event ordering
73+
# If you're using `waitFor`, switch to `await` and / or propagate the
74+
# up the call stack.
75+
doAssert not loop.polling, "The event loop and chronos functions in general are not reentrant"
76+
77+
loop.polling = true
78+
defer: loop.polling = false
79+
7780
func getAsyncTimestamp*(a: Duration): auto {.inline.} =
7881
## Return rounded up value of duration with milliseconds resolution.
7982
##
@@ -138,10 +141,10 @@ template processTicks(loop: untyped) =
138141
loop.callbacks.addLast(loop.ticks.popFirst())
139142

140143
template processCallbacks(loop: untyped) =
141-
while true:
142-
let callable = loop.callbacks.popFirst() # len must be > 0 due to sentinel
143-
if isSentinel(callable):
144-
break
144+
# Process existing callbacks but not those that follow, to allow the network
145+
# to regain control regularly
146+
for _ in 0..<loop.callbacks.len():
147+
let callable = loop.callbacks.popFirst()
145148
if not(isNil(callable.function)):
146149
callable.function(callable.udata)
147150

@@ -333,7 +336,6 @@ elif defined(windows):
333336
trackers: initTable[string, TrackerBase](),
334337
counters: initTable[string, TrackerCounter]()
335338
)
336-
res.callbacks.addLast(SentinelCallback)
337339
initAPI(res)
338340
res
339341

@@ -581,16 +583,13 @@ elif defined(windows):
581583

582584
proc poll*() =
583585
let loop = getThreadDispatcher()
586+
loop.preparePoll()
587+
584588
var
585589
curTime = Moment.now()
586590
curTimeout = DWORD(0)
587591
events: array[MaxEventsCount, osdefs.OVERLAPPED_ENTRY]
588592

589-
# On reentrant `poll` calls from `processCallbacks`, e.g., `waitFor`,
590-
# complete pending work of the outer `processCallbacks` call.
591-
# On non-reentrant `poll` calls, this only removes sentinel element.
592-
processCallbacks(loop)
593-
594593
# Moving expired timers to `loop.callbacks` and calculate timeout
595594
loop.processTimersGetTimeout(curTimeout)
596595

@@ -656,14 +655,10 @@ elif defined(windows):
656655
# We move tick callbacks to `loop.callbacks` always.
657656
processTicks(loop)
658657

659-
# All callbacks which will be added during `processCallbacks` will be
660-
# scheduled after the sentinel and are processed on next `poll()` call.
661-
loop.callbacks.addLast(SentinelCallback)
658+
# Process the callbacks currently scheduled - new callbacks scheduled during
659+
# callback execution will run in the next poll iteration
662660
processCallbacks(loop)
663661

664-
# All callbacks done, skip `processCallbacks` at start.
665-
loop.callbacks.addFirst(SentinelCallback)
666-
667662
proc closeSocket*(fd: AsyncFD, aftercb: CallbackFunc = nil) =
668663
## Closes a socket and ensures that it is unregistered.
669664
let loop = getThreadDispatcher()
@@ -754,7 +749,6 @@ elif defined(macosx) or defined(freebsd) or defined(netbsd) or
754749
trackers: initTable[string, TrackerBase](),
755750
counters: initTable[string, TrackerCounter]()
756751
)
757-
res.callbacks.addLast(SentinelCallback)
758752
initAPI(res)
759753
res
760754

@@ -1010,14 +1004,11 @@ elif defined(macosx) or defined(freebsd) or defined(netbsd) or
10101004
proc poll*() {.gcsafe.} =
10111005
## Perform single asynchronous step.
10121006
let loop = getThreadDispatcher()
1007+
loop.preparePoll()
1008+
10131009
var curTime = Moment.now()
10141010
var curTimeout = 0
10151011

1016-
# On reentrant `poll` calls from `processCallbacks`, e.g., `waitFor`,
1017-
# complete pending work of the outer `processCallbacks` call.
1018-
# On non-reentrant `poll` calls, this only removes sentinel element.
1019-
processCallbacks(loop)
1020-
10211012
# Moving expired timers to `loop.callbacks` and calculate timeout.
10221013
loop.processTimersGetTimeout(curTimeout)
10231014

@@ -1064,14 +1055,10 @@ elif defined(macosx) or defined(freebsd) or defined(netbsd) or
10641055
# We move tick callbacks to `loop.callbacks` always.
10651056
processTicks(loop)
10661057

1067-
# All callbacks which will be added during `processCallbacks` will be
1068-
# scheduled after the sentinel and are processed on next `poll()` call.
1069-
loop.callbacks.addLast(SentinelCallback)
1058+
# Process the callbacks currently scheduled - new callbacks scheduled during
1059+
# callback execution will run in the next poll iteration
10701060
processCallbacks(loop)
10711061

1072-
# All callbacks done, skip `processCallbacks` at start.
1073-
loop.callbacks.addFirst(SentinelCallback)
1074-
10751062
else:
10761063
proc initAPI() = discard
10771064
proc globalInit() = discard

tests/testbugs.nim

Lines changed: 0 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -98,40 +98,6 @@ suite "Asynchronous issues test suite":
9898

9999
result = r1 and r2
100100

101-
proc createBigMessage(size: int): seq[byte] =
102-
var message = "MESSAGE"
103-
var res = newSeq[byte](size)
104-
for i in 0 ..< len(result):
105-
res[i] = byte(message[i mod len(message)])
106-
res
107-
108-
proc testIndexError(): Future[bool] {.async.} =
109-
var server = createStreamServer(initTAddress("127.0.0.1:0"),
110-
flags = {ReuseAddr})
111-
let messageSize = DefaultStreamBufferSize * 4
112-
var buffer = newSeq[byte](messageSize)
113-
let msg = createBigMessage(messageSize)
114-
let address = server.localAddress()
115-
let afut = server.accept()
116-
let outTransp = await connect(address)
117-
let inpTransp = await afut
118-
let bytesSent = await outTransp.write(msg)
119-
check bytesSent == messageSize
120-
var rfut {.used.} = inpTransp.readExactly(addr buffer[0], messageSize)
121-
122-
proc waiterProc(udata: pointer) {.raises: [], gcsafe.} =
123-
try:
124-
waitFor(sleepAsync(0.milliseconds))
125-
except CatchableError:
126-
raiseAssert "Unexpected exception happened"
127-
let timer {.used.} = setTimer(Moment.fromNow(0.seconds), waiterProc, nil)
128-
await sleepAsync(100.milliseconds)
129-
130-
await inpTransp.closeWait()
131-
await outTransp.closeWait()
132-
await server.closeWait()
133-
return true
134-
135101
test "Issue #6":
136102
check waitFor(issue6()) == true
137103

@@ -146,6 +112,3 @@ suite "Asynchronous issues test suite":
146112

147113
test "Defer for asynchronous procedures test [Nim's issue #13899]":
148114
check waitFor(testDefer()) == true
149-
150-
test "IndexError crash test":
151-
check waitFor(testIndexError()) == true

tests/testmacro.nim

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -544,3 +544,11 @@ suite "Exceptions tracking":
544544
await raiseException()
545545

546546
waitFor(callCatchAll())
547+
548+
test "No poll re-entrancy allowed":
549+
proc testReentrancy() {.async.} =
550+
await sleepAsync(1.milliseconds)
551+
poll()
552+
553+
expect(Defect):
554+
waitFor testReentrancy()

0 commit comments

Comments
 (0)