Repository navigation
epoll: dispatch loop invokes callbacks for completions the owning queue no longer holds, crashing queueWrite's callback on head.? (the epoll counterpart of #169/#224/#227) #234
Description
Activity
This is a report I had my clanker produce - can confirm I am seeing this issue intermittently, adding it here in hopes that it will be useful.
Independent confirmation of this crash from Ghostty 1.3.1 on Arch/Omarchy.
I have now seen five crashes with the same
SIGSEGVat address0x108over four days. The two most recent crashes happened about ten seconds apart; repeating the same workload afterward succeeded, so the failure is intermittent.The practical trigger is an SSH session to a Linux development host followed by starting
herdr, a TUI that queries terminal palette colors. One symbolized core contained this in-flight PTY response:ESC ] 4;126;rgb:afaf/0000/8787 ESC \The remote program and SSH connection remain usable after restarting Ghostty. This is not yet a minimal reproducer, but it independently supports terminal-query replies as a way to exercise the faulty queued-write path. SSH/TCP timing may explain why identical launches do not always crash.
I rebuilt the distro's Ghostty 1.3.1 package with debug information while retaining
ReleaseFast. The latest core resolves to:#0 queue.Intrusive(...WriteRequest).pop libxev/src/queue.zig:48 self.head = next.next; #1 queueWrite callback libxev/src/watcher/stream.zig:844 #2 xev.Loop.tick libxev/src/backend/epoll.zig:450 #3 termio.Thread.threadMain ghostty/src/termio/Thread.zig:278GDB shows the same invalid state described in this issue:
next = 0x0 req_inner = 0x0 epoll result = .write(28)The 28-byte successful write result exactly matches the retained OSC response buffer. The queue is empty and
popdereferencesnext.next, producing theNULL + 0x108fault. Earlier crashes from the unmodified distro package have the same kernelsegfault at 108fingerprint.The affected configuration explicitly selects epoll:
# Workaround for Ghostty discussion #3224 on Hyprland async-backend = epoll
Environment:
Ghostty 1.3.1-arch2 Zig 0.15.2 Build mode ReleaseFast libxev revision 34fa50878aec6e5fa8f532867001ab3c36fae23e Linux 7.1.9-arch1-2 x86_64 Omarchy 4.0.1-1 / HyprlandI can retain the private core for additional targeted GDB queries, but will not upload it because it contains terminal session data.
I have a variant reproducer that fires reliably on an idle machine, which may be
useful for evaluating candidate fixes.The only real difference from the workload in the issue is that it never reads the
replies, so the pty input buffer fills and writes go partial/EAGAINcontinuously:#!/usr/bin/env bash while :; do for _ in $(seq 1 200); do printf '\e[c'; printf '\e[>c'; printf '\e[>q' printf '\e]10;?\e\\'; printf '\e]11;?\e\\' printf '\e[?u'; printf '\e[5n'; printf '\e[6n' printf '\eP+q544e\e\\' done done
ghostty --config-default-files=false --async-backend=epoll -e bash ./repro.shNo background CPU or fd-churn load:
build result Arch ghostty 1.3.1-arch2(ReleaseFast, stock)9/9 SIGSEGVat0x108, within 2–6 slocal ReleaseSafe 1.3.1 19/20 abort at epoll.zig:461either, with --async-backend=io_uring0/5 The ReleaseFast row is the production
queue.zig:48queued-write null dereference,
which the testing notes in #237 say that workload could not reproduce.Evaluating #237
I backported the three code changes from #237 (not the new tests, which need Zig
0.16) onto34fa5087, rebuilt Ghostty 1.3.1 ReleaseSafe against it, and ran 20 runs
per arm with the script above:arm aborts unpatched 19/20 #237 backported 16/20 Fisher exact, two-sided: p = 0.34 — not a significant difference. Both arms abort in
epoll_ctl(EPOLL_CTL_DEL)withFileDescriptorIncompatibleWithEpoll.That agrees with what @rsteenwyk already concluded in the PR itself, just with a
larger sample: the cancellation and duplicate-fd fixes are not sufficient for the
stale-registration problem tracked here.Environment: Ghostty 1.3.1, libxev
34fa5087, Zig 0.15.2, Linux 7.1.9-arch1-2,
Omarchy (which shipsasync-backend = epollby default per ghostty#3224, so this
backend is not an unusual configuration in practice).
Investigation assisted by Claude Code (Claude Opus). The reproducer and the run counts
above I verified directly.Independent Omarchy confirmation (see also omarchy#6868) of this crash, plus an
alternative to thecontinueguard in #239. Reproduced on stock 1.3.1 and tested
the #239 guard against my reproducer.Reproducer (idle box, no background load)
async-backend = epoll, flood terminal-query replies with nobody reading them
so pty writes go partial/EAGAIN continuously:# repro.sh while :; do for _ in $(seq 1 200); do printf '\e[c'; printf '\e[>c'; printf '\e[>q' printf '\e]10;?\e\\'; printf '\e]11;?\e\\' printf '\e[?u'; printf '\e[5n'; printf '\e[6n' printf '\eP+q544e\e\\' done done # ghostty --config-default-files=false --async-backend=epoll -e bash repro.sh
Stock 1.3.1 ReleaseFast (libxev
34fa5087, Arch build-id36aeb61c…):
5/5 SIGSEGV @ 0x108 in 2-6s. Same workload on--async-backend=io_uring:
0/5. Matches this issue's field table exactly.Symbolized (ReleaseFast+symbols build)
#0 queue.Intrusive(...Shared(epoll).WriteRequest).pop queue.zig:116 (self.head = next.next, next = r14 = 0) #1 queueWrite callback stream.zig:844 (q_inner.pop().?) #2 backend.epoll.Loop.tick epoll.zig:450 (c.callback(...)) #3 termio.Thread.threadMain_ ghostty Thread.zig:278Core readout at the crash:
q_inner.head == tail == 0; the firing completion's
userdatapoints at that same queue;op = .write;r = .write = <len>. i.e.
a write completion dispatched by epoll for a request the queue already popped.On the
continueguard (#239)The predicate is right (only
.activecompletions are legitimately outstanding).
On my reproducer the #239 build stayed alive with no crash, but its io thread ran
very hot under the flood; I could not cleanly separate a livelock spin from the
flood's own write work, so I can't independently confirm the livelock this issue
warns about - only thatcontinueleaves the stale event registered.Variant: retire the registration instead of skipping it
The only completion this layer ever arms is the queue head's (empty-push,
partial-rearm, next-readd and the initial arm all implyfiring == head), so a
delivery whose completion isn't the head is stale. Guard the generated
queueWrite callback (src/watcher/stream.zig) before thehead.?/pop():const q_inner = @as(?*xev.WriteQueue, @ptrCast(@alignCast(ud))).?; + +// STALE-DELIVERY GUARD: the only completion this layer arms is the one +// belonging to the queue head, so a firing completion that isn't the head's +// is a stale epoll registration from a prior incarnation of this pooled +// (wholesale-reset) request. Return .disarm so the dispatcher CTL_DELs the +// fd (the stale OUT event can't re-fire -> no livelock) and we never pop an +// empty queue. +const firing: *xev.WriteRequest = @fieldParentPtr("completion", c_inner); +if (q_inner.head != firing) return .disarm; const req_inner: *xev.WriteRequest = q_inner.head.?;
Results on the same reproducer: 0/20 crashes, with ~4.7 stale deliveries
caught per run (allfiring != head,r = .write). No dropped output on a
pty fast-reader conservation harness: 3/3 runs, 20000/20000 callbacks,
649488/649488 bytes exact. (A second harness driving sustained backpressure
via a pool-reuse ring did not finish cleanly and is inconclusive; I am not
claiming byte-conservation-under-backpressure yet.)Caveats
- Targeted guard for the queueWrite crash; does not address the fd-reuse root
paths in epoll:start()'s cancel branch guards on the cancel's ownstate == .addinginstead of the target's; canceling a dead or never-registered completion panics, and on a recycled fd it silently deregisters a live one #230/epoll: canceled TCP operations leak the duplicated fd and keep the peer's connection alive; cancellation isn't the only leaky path #231. A stale OUT registration on a still-armed pty fd is a
latent hazard either way - the durable fix is not leaking the registration
when a completion is retired. .disarmruns the dispatcher'sepoll_ctl(CTL_DEL)on the shared stream fd,
so it needs the same completion-lifecycle reasoning epoll: ignore events for completions that are no longer active #239 does; worth a second
pair of eyes before adopting.
Investigated and drafted by Claude (Opus 5) via omp; the reproducer, cores,
CPU samples, and conservation runs were verified by me on my own machine.- Targeted guard for the queueWrite crash; does not address the fd-reuse root
Summary
On the epoll backend, a
Completioncan have its callback invoked by an eventthat the queue owning it no longer considers outstanding. When that completion
belongs to a queued write, the callback generated by
queueWriteasserts aninvariant libxev cannot guarantee:
In
ReleaseFastthat.?is unchecked, so it becomes a null dereference. Wetraced a reproducible
SIGSEGVin Ghostty 1.3.1 to exactly this.This looks like the epoll member of the completion-reuse class already tracked
in #169 (generic), #224 (kqueue, open PR) and #227 (IOCP) — see "Relation to
existing issues" below.
The crash is provably an illegitimate delivery
This part needs no reproducer; it follows from the core dump plus libxev's own
design.
Ghostty 1.3.1 (Arch,
ReleaseFast, libxev34fa5087),SIGSEGVon theper-surface IO thread:
The faulting instruction is the inlined
queue.Intrusive(T).pop():Read out of the core:
WriteQueue(inrdi)head = 0,tail = 0— cleanly empty0x7fbb90013400completion.callback(+0x80)queueWrite's generated callbackcompletion.userdata(+0x78)WriteQueuethat is inrdioptag (+0x74)0x07=.writeflags(+0xc0)0x6d0→dup = true,dup_fd = 54WriteRequest.next(+0x108)0Field offsets identify the backend unambiguously:
nextat0x108,userdataat
0xd0,flagsat0xc0are the epoll layout (io_uring's are0xd0,0x98,0x90).Now the design argument. In
queueWrite, a completion is only ever handed toloop.add()when its request is — or is about to become — the queue head:queueWrite:if (q.empty()) loop.add(&req.completion); q.push(req);→ added only when the queue was empty, so that request becomes head.
if (q_inner.head) |req_next| l_inner.add(&req_next.completion);→ adds the new head.
req_inner, which has not been popped and isstill head.
So for any legitimate delivery,
q.headequals the firing request. At thecrash
q.head == 0while the firing request is0x7fbb90013400. They differ,therefore this delivery was not one the queue was expecting. The
.?thenturns that into a null dereference.
Reproducer
-Doptimize=ReleaseSafe(Zig 0.15.2), withasync-backend = epollin Ghostty's config.a reply written back to the pty: 800 rounds of
DA1 / DA2 / DSR / CPR / XTVERSION / kitty-keyboard / CSI 14t / CSI 18t / OSC 10 / OSC 11.Hit rate ~1 in 13 rounds under load; 0 in 10 rounds on an idle machine.
ReleaseSafedies earlier than production does, in the same dispatch loop:epoll_ctl(CTL_DEL)returningEPERMmeans that fd number no longer refers toanything epoll-compatible — it had been closed and reused. In
ReleaseFastcatch unreachableis UB, so execution continues and the followingposix.close(v)closes an unrelated descriptor whileself.active -= 1miscounts.
Instrumented observations
Logging at the top of the dispatch loop, behaviour otherwise unchanged:
op=write— the re-delivered completion is a write, i.e. one whose callbackperforms
q_inner.head.?.state=deadon entry — the loop is about to invoke a callback on a completionthat is not armed.
submitguards on state(
if (c.flags.state != .adding) continue;); the dispatch loop does not.dup_fd=0together withstate=dead—startsetsdup_fdviafd_maybe_dupand only then setsstate = .active, so this completion wasnever started in its current incarnation, yet epoll delivered an event
pointing at it.
The reading most consistent with that is that the registration belongs to a
previous incarnation of the same memory: Ghostty pools
WriteRequests in aring and resets them wholesale (
req.* = .{ .full_write_buffer = buf }), so aregistration that was never removed keeps delivering into recycled objects. We
did not instrument the pool itself, so that specific step is inference rather
than observation.
What is proven and what is not
Proven:
queueWrite's callback running against a queue thatdoes not contain the firing request, and
head.?dereferencing null.stream.zig'shead.?/pop().?assert an invariant the library does notenforce.
Not proven:
There is a concrete discrepancy: the production completion had
dup_fd = 54(it had been started and disarmed at some point), whereas thereproduced stale deliveries had
dup_fd = 0(recycled memory, never startedin that incarnation). Same class — a delivery to a completion the queue no
longer owns — but not demonstrably the same sub-path.
Two fixes that do NOT work
Recording these so nobody repeats them.
1. Guard the dispatch loop on state, mirroring
submit:The predicate is right: over 100 rounds it fired 0 times in healthy rounds, so
it never rejects a legitimate delivery. But epoll is level-triggered, and
continueskips the event without removing the registration, so the same eventre-fires immediately and forever. In the rounds where the bug triggered a single
completion spun 4.5M–10.7M times. This trades a rare crash for a livelock.
2. Preserve
flags.dup_fdacrosswriteInit.Fails at round 10 of 100. A detector for "
writeInitcalled on a completionwhose
state == .active" fired 0 times, sowriteInitis not where thebookkeeping is lost and there is nothing there to preserve.
Both point the same way: by the time the event is dispatched, the information
needed to unregister is already gone. A fix has to be on the retirement paths
that fail to remove the registration in the first place — which is #231's
territory and a design call we did not want to guess at.
Relation to existing issues
kqueue.zig,queue.zig).loop; queue: clear next before re-enqueue #170 does touch
stream.zigbut only the.rearmpush at :854, not thehead.?at :818.what lets a registration outlive its completion here; this issue is about the
consequence (misdelivery reaching application code), not the leak itself.
start()'s cancel branch guards on the cancel's ownstate == .addinginstead of the target's; canceling a dead or never-registered completion panics, and on a recycled fd it silently deregisters a live one #230 — also epoll and also involves a recycled fd, but instart()'scancel branch, a different path from the dispatch loop.
Note on backend selection
The affected machine sets
async-backend = epollin Ghostty's config — along-standing workaround for ghostty-org/ghostty discussion #3224 ("general
slowness on Hyprland"). So these processes are on epoll deliberately, not as
a fallback. Ghostty's default is
auto, which picks io_uring on a typical Linuxbox, and the io_uring path does not have this bug. That one config line is all
that is needed to reproduce.
Separately,
IO_Uring.available()creates a real 256-entry ring(
linux.IoUring.init(256, 0) catch return false), so a process that loses thatprobe at startup drops to epoll for its whole lifetime even under
auto. Weconfirmed this by launching with
RLIMIT_MEMLOCK=64K, which reproduces the samefd profile (0 io_uring fds, epoll loops). That is a second, unconfigured route
onto this code path.
Environment
34fa50878aec6e5fa8f532867001ab3c36fae23e(the rev Ghostty 1.3.1pins). The same code is present in the rev Ghostty
mainpins and in libxevmain(9ce8e8e6ff89e583258a7f8e7adeeeaeae8611bf).times over three days on the distro
ReleaseFastbuild.Investigated and written by Claude Opus 5 via Claude Code, posted by the machine
owner.