Skip to content

libxev IOCP: cursor-blink timer can re-init a queued Completion → renderer-thread panic (upstream mitchellh/libxev#227); mitigation running stably for ~3 weeks #3

Description

@nanasess

Summary

GhostInTheWSL inherits a crash from the libxev IOCP backend: the renderer thread's cursor-blink timer can be re-initialized while its Completion is still linked in the loop's completions queue, which panics on a null unwrap (and, from the same root cause, can silently orphan queued completions).

I filed the upstream analysis as mitchellh/libxev#227, and I have been running a mitigation in a fork for about three weeks with no recurrence. Opening this to report the result here, since IOCP is the backend GhostInTheWSL uses on Windows and upstream libxev marks IOCP as WIP.

Symptom

Intermittent crash of ghostinthewsl.exe during normal use, with a Zig safety panic (0x80000003) in the renderer thread at the c.result.? unwrap in libxev's src/backend/iocp.zig completions loop ("Completion queue items MUST have a result set").

Root cause

Loop.timer re-initializes the completion with c.* = .{...}, which resets every default field — including c.result and the intrusive c.next pointer. If the completion is still linked in self.completions at that moment, the next drain hits two problems:

  • c.result is null → the c.result.? unwrap panics.
  • c.next is null → head becomes null, so every completion queued behind it is dropped and its callback never fires (silent stalls).

GhostInTheWSL triggers this because the cursor-blink timer is re-armed / reset / cancelled from several independent events (blink re-arm, focus change, content-driven reset), so the same completion can be re-initialized while still queued.

This is the IOCP counterpart of the completion-lifecycle class described in libxev#169; libxev#170 and ghostty-org#224 only touch the shared/kqueue paths, so iocp.zig is currently uncovered by any open PR. Full write-up: mitchellh/libxev#227.

Mitigation I'm running

A one-line defensive guard at the top of the completions loop, applied before the reused state is touched:

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.
    if (c.result == null) continue;
    ...

To ship this without waiting on upstream, I override .libxev in build.zig.zon to a fork that is the exact commit GhostInTheWSL already pins (34fa508) plus this single patch — deliberately not tracking libxev main, so no unvetted changes come along.

I want to be clear that this is a mitigation, not a fix: it prevents the panic, but it does not address the underlying reuse-while-queued corruption (orphaned completions, double-cancellation). A real fix belongs in libxev.

Evidence

  • Crashes were reproducing regularly: the last dumps in %LOCALAPPDATA%\CrashDumps\ are dated 2026-06-23 and 2026-06-24.
  • The libxev override was applied on 2026-06-25.
  • Daily use since then, through 2026-07-19: no new crash dumps, and no recurrence of the pre-crash input/scroll stalls.

Why I'm reporting it here

Since upstream libxev has no open PR touching iocp.zig and lists IOCP as WIP, GhostInTheWSL is exposed to this for as long as it pins a libxev without the guard. There are a few directions you might reasonably take, and I did not want to presume which:

  1. Carry the same override in build.zig.zon until libxev fixes it upstream.
  2. Wait for libxev#227 and bump the pin when it lands.
  3. Treat the blink-timer usage on the GhostInTheWSL side as the thing to change, so the same completion is never re-armed while queued (arguably the more correct layer, but a bigger change).

Happy to send a PR for (1) if that's useful — it is a build.zig.zon url/hash change only. Equally happy if you'd rather just track (2). And if you can reproduce or have seen matching crash reports, that would be useful signal for libxev#227, which currently has no other reporters.

AI disclosure

Per AI_POLICY.md: I used Claude Code to help analyze the minidumps and trace the mechanism through libxev's queue/backend code, and to draft this write-up, which I then reviewed and edited. I verified the mechanism against the pinned libxev source and confirmed the result on real hardware over the soak period described above.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions