Skip to content

fix(tail): flush stdin write buffer on SIGINT/SIGTERM - #307

Open
detail-app[bot] wants to merge 1 commit into
masterfrom
detail/bug-fix/fix-tail-flush-stdin-write-buffer-on-sigint-sigter-9b4881
Open

detail-app[bot] wants to merge 1 commit into
masterfrom
detail/bug-fix/fix-tail-flush-stdin-write-buffer-on-sigint-sigter-9b4881

Conversation

@detail-app

@detail-app detail-app Bot commented Sep 16, 2026

Copy link
Copy Markdown

Detail bug report: View on Detail

Fixes ENG-708

Bug

edge-tail in stdin mode (edge-tail / edge-tail -) lost up to ~64 KiB of output on SIGINT/SIGTERM. The stdin path (runStdinToOutputRuntime.runStream) never installed a signal handler and only flushed the write buffer after framer.pump returned (i.e. on stdin EOF). On Ctrl-C / supervisor stop the process was hard-killed with default signal disposition before that lone output.flush() could run, dropping the write-buffer residual (bounded by write_buf, 64 KiB default). The file-tailing path was thought to be unaffected, but its signal handling is also broken (see out-of-scope note below).

During investigation I found the report's "mirror the file loop" sketch wouldn't have worked: (1) the Threaded Io backend only cancels syscalls on registered worker threads (Thread.current is set solely in Threaded.worker), so a blocking readv on the main thread can't be interrupted — the pump must run as a lifecycle task; (2) File.Reader.readVecStreaming converts the cancel's error.Canceled into error.ReadFailed, and readSliceShort fills the full buffer (blocking mid-fill on a held-open pipe) while stranding a prefetched ~64 KiB in the reader's internal buffer — a second loss term the report didn't account for.

Fix

Apply structured shutdown to the stdin path, mirroring the file loop's pattern but with two corrections the file loop is missing:

  • Run the pump as a lifecycle task (StdinLoop) on a worker thread so Group.cancel can interrupt the blocking read.
  • Block SIGINT/SIGTERM/SIGUSR1 via installSignalWaiter before spawning the worker so the worker inherits the blocked mask; otherwise the signal lands on the worker with default disposition and hard-kills the process before sigwait can act.
  • Add framer.pumpFileStreaming reading stdin via file.readStreaming directly — one short readv per iteration, no reader-side internal-buffer prefetch, and error.Canceled propagates natively (not converted to ReadFailed). This eliminates both the read-buffer prefetch loss and the ReadFailed misclassification.
  • On signal → requestShutdown (sigwait thread) → main thread awaitShutdowngroup.cancel interrupts the readv → pump unwinds → main thread output.flush() drains the residual. On EOF the pump completes and wakes the main via a new Lifecycle.requestShutdownQuiet (clean stderr for the common echo | edge-tail - case); the main cancels/joins via group.cancel (no "all tasks drained" log).

Testing

  • Added a Zig unit test for pumpFileStreaming (short reads across line boundaries → byte-exact EOF output) and one for the StdinLoop lifecycle coordination (spawn → awaitShutdown → cancel → main-thread flush → bytes on disk).
  • Added black-box Python tests in bench/logging/tests/test_stdin.py: SIGINT mid-stream flushes residual, SIGTERM mid-stream flushes residual, and the FIFO (named-pipe, write-end held open) SIGINT scenario from the bug report — all assert exit == 0 and output == input (0 bytes lost).
  • zig build test (521 passed, 1 pre-existing skip), zig fmt --check, ziglint, zig build (all targets), and a fresh ReleaseSafe zig build tail all pass.
  • Manual reproduction against the built binary, all lost=0, exit=0: EOF baseline (clean stderr, full data), SIGINT and SIGTERM mid-stream on an anonymous pipe, an input-size sweep (32 KiB–1 MiB), and the FIFO scenario. Pre-fix the same scenarios gave exit=-2 with ~64 KiB lost.
  • The file-tailing tests (test_checkpoint_recovery with SIGTERM/SIGKILL, test_concurrency_stress, plus append/output/startup/glob/policy/rotation/perf-guards modules) all pass — the file-tailing path is unchanged by this fix.
  • Could not externally exercise the second-signal force-quit path (signalWaiterThread's count==2 → std.process.exit(1)): POSIX standard signals coalesce when blocked via sigprocmask, and the now-reliable ~5 ms cooperative shutdown completes before a deliberately-delayed second signal is consumed separately, so every double-kill trial exits 0. The code path is structurally unchanged (src/tail/runtime.zig:72-85) and remains the safety valve for stuck shutdowns.
  • Two pre-existing test failures (test_over_limit_line_is_truncated, test_path_missing_then_reappears) confirmed via git stash + rebuild to fail identically on the unmodified tree — not regressions.

Out of scope

The file-tailing loop (runFilesLoopBackend) spawns its workers before blocking signals, so its SIGINT/SIGTERM handling has the same hard-kill defect (verified: edge-tail <growing-file> dies with exit -2 on SIGINT). The bug report assumed the file loop worked; it does not. Fixing it requires reordering signal blocking before checkpoint.start/lifecycle.spawn there — a separate change.


Automatic Fixes PRs can be configured here.

@macroscopeapp

macroscopeapp Bot commented Sep 16, 2026

Copy link
Copy Markdown

Approvability

Verdict: Not approved

Macroscope's review found this PR not approvable — The fix addresses a clear stdin flush bug and adds strong EOF, SIGINT, SIGTERM, and FIFO coverage. However, it also replaces the existing stdin path with new worker-thread, signal-mask, and cooperative-shutdown coordination, creating enough concurrency and shutdown complexity to warrant human review.

You can add or adjust custom eligibility rules. Learn more.

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

Labels

None yet

Development

Successfully merging this pull request may close these issues.

1 participant