Skip to content

timeout: replace 100ms polling loop with setitimer for precise timeouts#12352

Open
gabrielhnf wants to merge 1 commit into
uutils:mainfrom
gabrielhnf:fix-timeout-use-itimer
Open

timeout: replace 100ms polling loop with setitimer for precise timeouts#12352
gabrielhnf wants to merge 1 commit into
uutils:mainfrom
gabrielhnf:fix-timeout-use-itimer

Conversation

@gabrielhnf
Copy link
Copy Markdown

Fixes #11615 and #9099

Problem

uu timeout was less precise than GNU timeout by design. The core of the issue was in wait_or_timeout in process.rs, which used a polling loop sleeping in 100ms increments:

thread::sleep(Duration::from_millis(100));

This meant that for any timeout shorter than 100ms, or any timeout whose deadline fell between two polling ticks, uu timeout would overshoot by up to 100ms. For latency-sensitive use cases this was a meaningful regression from GNU.

Before:

$ time timeout 0.001 sleep 10
Executed in    102.03 millis

After:

$ time timeout 0.001 sleep 10
Executed in    1.24 millis

Solution

On Unix, replace the polling loop with setitimer(ITIMER_REAL, ...), which delivers SIGALRM to the process at the specified time with microsecond precision. This is the same mechanism GNU timeout uses (GNU prefers timer_create + timer_settime for nanosecond precision, falling back to setitimer; see NOTES below).

Design overview

The implementation adds three new functions and a supporting enum:

arm_timer(duration) -> TimerHandle

Arms the itimer for the given duration. Returns TimerHandle::Posix on success or TimerHandle::Polling if setitimer fails, allowing graceful fallback to the original polling path. Sub-microsecond durations are clamped to 1 microsecond, since a zero itimerval disarms the timer rather than firing it. A duration of exactly Duration::ZERO bypasses the itimer entirely and falls through to polling, which correctly handles the "no timeout" semantics.

disarm_timer()

Disarms the itimer by writing a zeroed itimerval. Called after waitpid returns so the timer cannot fire during later execution.

wait_with_itimer(process, foreground) -> io::Result<Option<ExitStatus>>

Replaces the wait_or_timeout call in the main wait path. Calls libc::waitpid directly rather than going through Child::wait, because Child::wait wraps waitpid in an EINTR-retry loop, which would silently swallow the EINTR we need to observe when SIGALRM fires.

The loop distinguishes three cases on EINTR:

  1. SIGNALED && RECEIVED_SIGNAL == SIGALRM: the timer fired. Disarm and
    return Ok(None) — child is still alive, caller sends term_signal.
  2. SIGNALED && RECEIVED_SIGNAL != SIGALRM: an external signal (e.g.
    SIGINT, SIGTERM) was delivered to the timeout process. Forward it to the
    child via send_signal, reset the flag, and retry waitpid.
  3. Neither: EINTR from some other source, retry waitpid.

A pre-loop check handles the race where the timer fires before waitpid is even called (relevant for very short durations after clamping to 1µs).

Signal handler changes

All signal handlers are now registered with sigaction using SaFlags::empty(), intentionally omitting SA_RESTART, which was being implicitly set by nix::signal. Without SA_RESTART, waitpid returns EINTR when any signal arrives, giving the loop a chance to inspect RECEIVED_SIGNAL and act accordingly.

External signal forwarding

When an external signal interrupts waitpid, wait_with_itimer forwards it to the child immediately using the existing send_signal function, which correctly handles the foreground flag.

--kill-after path

wait_or_kill_process is updated to use wait_with_itimer on the same TimerHandle::Posix path, so --kill-after also benefits from itimer precision. The SIGNALED and RECEIVED_SIGNAL atomics are reset before entering the kill_after phase so stale state from the first timeout does not corrupt the second wait.

Notes

timer_create / timer_settime not yet used.
GNU timeout prefers timer_create(CLOCK_REALTIME) for nanosecond resolution. This PR stops at setitimer (microsecond precision) mainly due to portability, since some UNIX don't implement timer_create. A follow up can add timer_create as the preferred path with setitimer as fallback, matching GNU's priority order exactly.

@github-actions
Copy link
Copy Markdown

github-actions Bot commented May 17, 2026

GNU testsuite comparison:

Skip an intermittent issue tests/tail/retry (fails in this run but passes in the 'main' branch)
Skipping an intermittent issue tests/date/date-locale-hour (passes in this run but fails in the 'main' branch)
Note: The gnu test tests/expand/bounded-memory is now being skipped but was previously passing.
Congrats! The gnu test tests/cp/link-heap is now passing!
Congrats! The gnu test tests/tail/tail-n0f is now passing!

@gabrielhnf gabrielhnf force-pushed the fix-timeout-use-itimer branch from a476264 to 251444d Compare May 17, 2026 15:00
@gabrielhnf
Copy link
Copy Markdown
Author

The test_mkfifo failures appear to be unrelated to this PR, can someone confirm?

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.

uu timeout is less precise than GNU timeout

1 participant