write: Interrupted system call — FdOutputStream::append() does not retry write() on EINTR from non-cancel signals (e.g. SIGWINCH)
Description
FdOutputStream::append() in src/io.rs permanently kills the output stream when write() is interrupted by a benign signal like SIGWINCH (terminal resize). Only EINTR from SIGINT/SIGHUP is handled gracefully — all other EINTR causes are treated as fatal errors.
This is the write() equivalent of #10250, which was fixed for open_cloexec() in PR #10251.
Steps to reproduce
In tmux, with a large fish history (thousands of entries):
builtin history -z --show-time="%s%t" | fzf-tmux -d 35% -- --read0
Or simply press Ctrl+R to use fzf's built-in fzf-history-widget, which pipes builtin history to fzf.
Observed behavior
write: Interrupted system call is printed
- fzf receives only ~660 entries instead of the full history (52,000+ in my case)
- The count is consistent across invocations — it's however many entries get written before
fzf-tmux creates a split pane and triggers SIGWINCH
Expected behavior
All history entries should be piped to fzf. The write should be retried on EINTR from SIGWINCH.
Root cause
In src/io.rs, FdOutputStream::append() (lines ~757-778):
if unescape_bytes_and_write_to_fd(s, self.fd).is_none() {
if errno::errno().0 == EINTR && self.sigcheck.check() {
// Only handles EINTR + SIGINT/SIGHUP (SigHupInt topic)
// SIGWINCH falls through
} else if errno::errno().0 != EPIPE {
perror("write"); // prints "write: Interrupted system call"
}
self.errored = true; // permanently kills the output stream
}
self.sigcheck is SigChecker::new(Topic::SigHupInt) — it only detects SIGHUP and SIGINT. When SIGWINCH (or any other benign signal) causes EINTR:
self.sigcheck.check() returns false
- Code falls through to
perror("write")
self.errored = true — all future append() calls bail immediately
- The pipe closes, downstream consumer gets truncated data
What triggers SIGWINCH
fzf-tmux creating a tmux split pane (resizes the original pane)
fzf --height adjusting terminal layout
fzf --tmux creating a tmux popup
- User manually resizing the terminal during a long-running pipe
Suggested fix
Similar to PR #10251 for open_cloexec(): retry write() on EINTR when it's not from a cancel signal.
Ideally, make unescape_bytes_and_write_to_fd retry on EINTR unconditionally (EINTR-transparent), and check for user cancellation separately in append() via sigcheck:
fn append(&mut self, s: impl IntoCharIter) -> bool {
if self.errored {
return false;
}
if self.sigcheck.check() {
self.errored = true;
return false;
}
if unescape_bytes_and_write_to_fd(s, self.fd).is_none() {
if errno::errno().0 != EPIPE {
perror("write");
}
self.errored = true;
}
!self.errored
}
With the write helper retrying internally:
loop {
match write(fd, &buf[bytes_written..]) {
Ok(n) => { bytes_written += n; if bytes_written >= buf.len() { break; } }
Err(e) if e == EINTR => continue, // always retry
Err(_) => return None,
}
}
This also handles partial writes correctly (resumes from offset rather than re-sending the full buffer).
Other potentially affected sites
The same pattern may exist elsewhere — a grep -rn "EINTR" src/ audit of all write-side EINTR handling would be worthwhile.
Workaround
Override fzf-history-widget to not pipe builtin history to fzf. Instead, let fzf execute FZF_DEFAULT_COMMAND in a child process (which won't receive SIGWINCH):
# In ~/.config/fish/functions/fzf-history-widget.fish
# Change:
# eval $FZF_DEFAULT_COMMAND \| (__fzfcmd) --query=$fzf_query
# To:
# eval (__fzfcmd) --query=$fzf_query
Environment
- fish 4.5.0 (Rust port)
- fzf 0.68.0
- tmux 3.6a
- Linux 6.19.3
write: Interrupted system call— FdOutputStream::append() does not retry write() on EINTR from non-cancel signals (e.g. SIGWINCH)Description
FdOutputStream::append()insrc/io.rspermanently kills the output stream whenwrite()is interrupted by a benign signal like SIGWINCH (terminal resize). Only EINTR from SIGINT/SIGHUP is handled gracefully — all other EINTR causes are treated as fatal errors.This is the
write()equivalent of #10250, which was fixed foropen_cloexec()in PR #10251.Steps to reproduce
In tmux, with a large fish history (thousands of entries):
Or simply press Ctrl+R to use fzf's built-in
fzf-history-widget, which pipesbuiltin historyto fzf.Observed behavior
write: Interrupted system callis printedfzf-tmuxcreates a split pane and triggers SIGWINCHExpected behavior
All history entries should be piped to fzf. The write should be retried on EINTR from SIGWINCH.
Root cause
In
src/io.rs,FdOutputStream::append()(lines ~757-778):self.sigcheckisSigChecker::new(Topic::SigHupInt)— it only detects SIGHUP and SIGINT. When SIGWINCH (or any other benign signal) causes EINTR:self.sigcheck.check()returnsfalseperror("write")self.errored = true— all futureappend()calls bail immediatelyWhat triggers SIGWINCH
fzf-tmuxcreating a tmux split pane (resizes the original pane)fzf --heightadjusting terminal layoutfzf --tmuxcreating a tmux popupSuggested fix
Similar to PR #10251 for
open_cloexec(): retrywrite()on EINTR when it's not from a cancel signal.Ideally, make
unescape_bytes_and_write_to_fdretry on EINTR unconditionally (EINTR-transparent), and check for user cancellation separately inappend()viasigcheck:With the write helper retrying internally:
This also handles partial writes correctly (resumes from offset rather than re-sending the full buffer).
Other potentially affected sites
The same pattern may exist elsewhere — a
grep -rn "EINTR" src/audit of all write-side EINTR handling would be worthwhile.Workaround
Override
fzf-history-widgetto not pipebuiltin historyto fzf. Instead, let fzf executeFZF_DEFAULT_COMMANDin a child process (which won't receive SIGWINCH):Environment