Repository navigation
Description
TL;DR
On the IOCP backend, re-initializing/re-arming a Completion while it is still linked in self.completions corrupts the event loop. Loop.timer's c.* = .{...} resets the whole struct, clearing both c.result and the intrusive c.next pointer, which produces two symptoms from one root cause:
- Null-result panic —
completions.pop()later unwrapsc.result.?("Completion queue items MUST have a result set") on the cleared result →attempt to use null value. - Orphaned completions — because
c.nextwas also cleared, poppingcsetshead = null, permanently dropping every completion queued afterc(their callbacks never fire → silent stalls). Same defect as [bug] v.next is not proper cleared for re-enqueue a completion between different queues #169.
This is the IOCP counterpart of the multi-queue-membership class that #169 describes and that #224 is fixing for kqueue — but no open PR touches iocp.zig.
Verified mechanism (against 34fa508)
queue.Intrusive.popdoesself.head = next.nextthennext.next = null(src/queue.zig);pushassertsv.next == null.Completion.next: ?*Completion = null— a default field, soc.* = .{...}inLoop.timerresets it to null.- So if
cis inself.completionsas[head=c → D → E]and any path re-initializes it vialoop.timer(c, ...)(or the.activebranch oftimer_reset), then on the next drain:c.result→ null → panic at thec.result.?unwrap in the completions loop (src/backend/iocp.zig).c.next→ null →headbecomes null andD,Eare orphaned.
Secondary hazard: double-cancellation
A canceled timer is pushed to self.completions with its state kept .active (so the active count can be decremented on processing). If timer_reset runs on it while it is still pending there, it sees .active and schedules a second cancellation; the second stop_completion then removes the timer from self.timers where it no longer exists (heap corruption) and pushes it to the queue again.
How it was hit
A Ghostty-based Windows terminal (ghostinthewsl) on IOCP: the cursor-blink timer (renderer thread) re-arms/resets/cancels the same timer completion from several events (blink re-arm, focus change, content-driven reset), so it can be re-initialized while still queued. Symptoms matched exactly — intermittent crashes, and on an older related port, input/scroll freezing preceding the crash (the orphaned-completion stall). Windows/IOCP is WIP per the README.
Relation to #169 / #170 / #224
- [bug] v.next is not proper cleared for re-enqueue a completion between different queues #169 is exactly this class ("
nextnot cleared when re-enqueuing a completion between queues"). - queue: clear next before re-enqueue #170 clears
nextbefore re-enqueue but only in shared/kqueue paths — it does not touchiocp.zig. - kqueue: fix tick(0) EV_DELETE flush, rearm state, and submit queue corruption #224 (open) fixes three kqueue completion-lifecycle bugs, including a defensive
c.next = nullbeforesubmit()pushes a kevent-returned completion (its fix Stream HLA #3, "Fixes [bug] v.next is not proper cleared for re-enqueue a completion between different queues #169"). But kqueue: fix tick(0) EV_DELETE flush, rearm state, and submit queue corruption #224 only changeskqueue.zig/queue.zig— IOCP is untouched, and none of these address theresult-null symptom. - So the kqueue side of this class is being handled in kqueue: fix tick(0) EV_DELETE flush, rearm state, and submit queue corruption #224; this issue is specifically the uncovered IOCP gap (and the
resultsymptom). (The kqueue side is intentionally out of scope here — kqueue: fix tick(0) EV_DELETE flush, rearm state, and submit queue corruption #224 owns it.)
What I ran in production (a mitigation, not a fix)
As a pragmatic guard in a fork, I skip a popped entry whose result is null, before touching its reused state:
diff --git a/src/backend/iocp.zig b/src/backend/iocp.zig
@@ -259,6 +259,17 @@ pub const Loop = struct {
// Process the completions we already have completed.
while (self.completions.pop()) |c| {
+ // Mitigation (not a fix): a stale entry whose result was cleared
+ // because the completion was re-initialized (c.* = .{...}) while
+ // still linked here. Skip it before touching the reused state.
+ // Prevents the c.result.? panic; does NOT fix the orphaned-
+ // completion / double-cancellation corruption from reuse-while-queued.
+ if (c.result == null) continue;
+
const c_active = c.flags.state == .active;Full patch (also carries a parity guard in kqueue, which I'd defer to #224): https://patch-diff.githubusercontent.com/raw/nanasess/libxev/pull/1.patch
Why this mitigation is empirically sufficient for this case
With a single repeating timer, the queued completion typically has no successors, so clearing its next orphans nothing — only the result-null panic remains, which the guard skips. A -Doptimize=ReleaseFast build has run ~1 week with zero crashes (previously it crashed within minutes–hours). Note that in ReleaseFast neither the c.result.? safety check nor assert(v.next == null) fires, so the corruption would be silent there; the explicit if (c.result == null) guard still takes effect. The general case (a queued completion with successors) is not mitigated — that needs the ownership fix.
Suggested direction
Consistent with #169 / #170 / #224: prevent a completion from being re-initialized/re-armed while it is a member of an intrusive queue (or dequeue it safely first), rather than clearing next / result after the fact. The correct fix touches the loop's ownership model, so I'm leaving the design to you.
Disclosure / environment
- I'm not fluent in Zig, and I'm not confident this patch is the correct fix — it's a mitigation I verified empirically, not something I can vouch for at the internals level. This was diagnosed and written with AI assistance. I'm providing it as a reference patch only (not a PR) and leaving the proper fix to you.
- Windows 11 + WSL2,
zig 0.15.2,-Dtarget=x86_64-windows-gnu. Minidumps / symbolized stacks available on request.
Activity