Skip to content

backend: skip stale completion-queue entries with a null result (IOCP + kqueue)#1

Open
nanasess wants to merge 1 commit into
mainfrom
fix/iocp-kqueue-stale-completion-null-result
Open

backend: skip stale completion-queue entries with a null result (IOCP + kqueue)#1
nanasess wants to merge 1 commit into
mainfrom
fix/iocp-kqueue-stale-completion-null-result

Conversation

@nanasess

@nanasess nanasess commented Jun 26, 2026

Copy link
Copy Markdown
Owner

Record / fork-local patch. This PR documents the fix carried by this fork; it is not intended for upstream merge as-is (upstream IOCP is WIP and the related upstream issues are stalled — see below). It is the single patch that nanasess/ghostinthewsl pins via build.zig.zon.

Problem

A completion popped from the loop's completion queue is unwrapped with c.result.? ("Completion queue items MUST have a result set"). That invariant can be violated by a completion-reuse race: if a completion is re-initialized and re-queued elsewhere while it is still linked in the completion queue, loop.timer (c.* = .{...}) resets result to null, so the later pop hits c.result.? and panics ("attempt to use null value").

Observed on Windows/IOCP via the Ghostty cursor-blink timer (renderer thread): the cursor timer completion is re-armed/reset/canceled from several renderer-thread events, and an interleaving leaves a stale entry whose result was cleared.

Fix

Guard both the IOCP and kqueue completion loops: when a popped entry has a null result, treat it as a stale entry and skip it without clobbering its now-reused state (the re-armed completion fires on its own).

Scope / caveats

  • IOCP (iocp.zig): the actually-affected backend; verified empirically (Debug build, multi-day soak, no recurrence; prior scroll-stall precursor gone).
  • kqueue (kqueue.zig): included for parity since it has the identical c.result.? pattern, but untested on macOS — the consumer (ghostinthewsl) is Windows-only and never exercises this path.
  • Branch base is the exact commit pinned by ghostinthewsl (34fa508); this fork is intentionally "behind" upstream main (pinned commit + minimal patch).

Relation to upstream

Consumer: nanasess/ghostinthewsl#4 (issue) / nanasess/ghostinthewsl#5 (override PR).

Disclosure: drafted with AI assistance and verified empirically; the author is not deeply familiar with Zig.

Closes #2.

… + kqueue)

A completion popped from the loop's completion queue is unwrapped with
`c.result.?` ("Completion queue items MUST have a result set"). That
invariant can be violated by a completion-reuse race: if a completion is
re-initialized and re-queued elsewhere while it is still linked in the
completion queue, `loop.timer` (`c.* = .{...}`) resets `result` to null,
so the later pop hits `c.result.?` and panics ("attempt to use null
value").

This was observed on Windows/IOCP via the Ghostty cursor-blink timer
(renderer thread): the cursor timer completion is re-armed/reset/canceled
from several renderer-thread events, and an interleaving leaves a stale
entry whose result was cleared. Windows IOCP support is still WIP per the
README, and this path is uncovered by mitchellh#170 (which only clears `next` and
does not touch iocp.zig).

Guard both the IOCP and kqueue completion loops: when a popped entry has a
null result, treat it as stale and skip it WITHOUT clobbering its now-reused
state (the re-armed completion fires on its own).

Disclosure: drafted with AI assistance and verified empirically; the author
is not deeply familiar with Zig.
@coderabbitai

coderabbitai Bot commented Jun 26, 2026

Copy link
Copy Markdown

Warning

Review limit reached

@nanasess, we couldn't start this review because you've reached your PR review rate limit.

More reviews will be available in 25 minutes and 52 seconds. Learn how PR review limits work.

Your organization has used up its prepaid credits, and credit purchases are no longer available. Enable the review add-on in the billing tab to keep reviews running — you're only billed for reviews past your plan's rate limits ($0.25/file).

⌛ How to resolve this issue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based credits.

🚦 How do rate limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please see our Fair Usage Limits Policy for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 4a8b5a54-90c6-41d2-941a-f25d5c9df1e3

📥 Commits

Reviewing files that changed from the base of the PR and between 9ce8e8e and de97685.

📒 Files selected for processing (2)
  • src/backend/iocp.zig
  • src/backend/kqueue.zig
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/iocp-kqueue-stale-completion-null-result

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request adds a guard to skip stale completions with a null result in both the IOCP and kqueue backends. However, the review feedback highlights that this change masks severe underlying state corruption issues. Re-initializing a completion while it is still in the intrusive queue resets its next pointer, severing the queue and permanently orphaning subsequent completions. Additionally, resetting a pending timer can trigger double-cancellation and corrupt the intrusive heap. Instead of skipping null results, the event loop should prevent completions from being re-initialized while queued, or safely dequeue them first.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment thread src/backend/iocp.zig
// state below; the re-armed completion fires on its own. Windows
// IOCP support is WIP (see README); this guards a completion-reuse
// race observed with the cursor blink timer (renderer thread).
if (c.result == null) continue;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

critical

⚠️ Critical Correctness & State Corruption Issues

While this guard prevents the immediate "attempt to use null value" panic, it papers over two extremely severe internal state corruptions within the event loop:

1. Intrusive Queue Corruption via c.* = .{...}

self.completions is an intrusive queue (queue.Intrusive(Completion)). It relies on the next pointer inside the Completion struct to maintain the linked list.

  • When a completion c is in self.completions (waiting for its callback to be processed), and loop.timer(c, ...) is called to re-initialize it, the struct assignment c.* = .{...} is executed.
  • This assignment resets the entire struct, setting c.next to null (its default value).
  • If there were other completions queued after c (e.g., c -> D -> E), the link to D is severed.
  • When c is popped, self.completions.head becomes c.next (which is now null). Any subsequent completions (D, E, etc.) are permanently orphaned and lost, causing their callbacks to never fire (leading to silent hangs/stalls).

2. Double-Cancellation & Heap/Queue Corruption via timer_reset

If a timer T is canceled, it is pushed to self.completions with its state purposely remaining .active so the active count can be decremented later.

  • If timer_reset is called on T while it is still pending in self.completions, timer_reset sees the .active state and schedules a new cancellation c_cancel2.
  • When c_cancel2 is processed, stop_completion is called on T again.
  • This attempts to remove T from self.timers (where it no longer exists, corrupting the intrusive heap) and pushes T to self.completions a second time (corrupting the queue or creating an infinite loop cycle).

Recommendation

Re-initializing or re-arming a completion that is currently pending in the completion queue is a violation of the event loop's ownership model. Instead of skipping null results, the event loop needs to prevent completions from being re-initialized while they are still members of an intrusive queue, or safely disassociate/dequeue them first.

Comment thread src/backend/kqueue.zig
// state below; the re-armed completion fires on its own. This
// mirrors the same guard in the IOCP backend and protects against
// a completion-reuse race (e.g. the cursor blink timer).
if (c.result == null) continue;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

critical

⚠️ Critical Correctness & State Corruption Issues

While this guard prevents the immediate "attempt to use null value" panic, it papers over two extremely severe internal state corruptions within the event loop:

1. Intrusive Queue Corruption via c.* = .{...}

self.completions is an intrusive queue (queue.Intrusive(Completion)). It relies on the next pointer inside the Completion struct to maintain the linked list.

  • When a completion c is in self.completions (waiting for its callback to be processed), and loop.timer(c, ...) is called to re-initialize it, the struct assignment c.* = .{...} is executed.
  • This assignment resets the entire struct, setting c.next to null (its default value).
  • If there were other completions queued after c (e.g., c -> D -> E), the link to D is severed.
  • When c is popped, self.completions.head becomes c.next (which is now null). Any subsequent completions (D, E, etc.) are permanently orphaned and lost, causing their callbacks to never fire (leading to silent hangs/stalls).

2. Double-Cancellation & Heap/Queue Corruption via timer_reset

If a timer T is canceled, it is pushed to self.completions with its state purposely remaining .active so the active count can be decremented later.

  • If timer_reset is called on T while it is still pending in self.completions, timer_reset sees the .active state and schedules a new cancellation c_cancel2.
  • When c_cancel2 is processed, stop_completion is called on T again.
  • This attempts to remove T from self.timers (where it no longer exists, corrupting the intrusive heap) and pushes T to self.completions a second time (corrupting the queue or creating an infinite loop cycle).

Recommendation

Re-initializing or re-arming a completion that is currently pending in the completion queue is a violation of the event loop's ownership model. Instead of skipping null results, the event loop needs to prevent completions from being re-initialized while they are still members of an intrusive queue, or safely disassociate/dequeue them first.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

IOCP/kqueue: panic on c.result.? when a completion is reused while still queued (cursor-blink timer)

1 participant