summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorMehmet Samet Duman <yongdohyun@projecttick.org>2026-03-10 17:23:33 +0300
committerMehmet Samet Duman <yongdohyun@projecttick.org>2026-03-10 17:23:33 +0300
commitf4f5c8986d45eb2db27a9a7c1439e60d3f22260c (patch)
treeaff4d1b9fb284a3fd523e092e5044bfe30458171
downloadProject-Tick-f4f5c8986d45eb2db27a9a7c1439e60d3f22260c.tar.gz
Project-Tick-f4f5c8986d45eb2db27a9a7c1439e60d3f22260c.zip
init Standalone Linux-native port of FreeBSD `timeout` for Project Tick BSD/Linux Distribution.
Signed-off-by: Mehmet Samet Duman <yongdohyun@projecttick.org>
-rw-r--r--.gitignore2
-rw-r--r--GNUmakefile37
-rw-r--r--LICENSE25
-rw-r--r--README.md47
-rwxr-xr-xtests/test.sh285
-rw-r--r--timeout.1292
-rw-r--r--timeout.c1016
7 files changed, 1704 insertions, 0 deletions
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000000..a07d5ed8b8
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,2 @@
+/build/
+/out/
diff --git a/GNUmakefile b/GNUmakefile
new file mode 100644
index 0000000000..8b19b9f278
--- /dev/null
+++ b/GNUmakefile
@@ -0,0 +1,37 @@
+.DEFAULT_GOAL := all
+
+CC ?= cc
+CPPFLAGS ?=
+CPPFLAGS += -D_POSIX_C_SOURCE=200809L
+CFLAGS ?= -O2
+CFLAGS += -std=c17 -g -Wall -Wextra -Werror
+LDFLAGS ?=
+LDLIBS ?=
+LDLIBS += -lm
+
+OBJDIR := $(CURDIR)/build
+OUTDIR := $(CURDIR)/out
+TARGET := $(OUTDIR)/timeout
+OBJS := $(OBJDIR)/timeout.o
+
+.PHONY: all clean dirs status test
+
+all: $(TARGET)
+
+dirs:
+ @mkdir -p "$(OBJDIR)" "$(OUTDIR)"
+
+$(TARGET): $(OBJS) | dirs
+ $(CC) $(LDFLAGS) -o "$@" $(OBJS) $(LDLIBS)
+
+$(OBJDIR)/timeout.o: $(CURDIR)/timeout.c | dirs
+ $(CC) $(CPPFLAGS) $(CFLAGS) -c "$(CURDIR)/timeout.c" -o "$@"
+
+test: $(TARGET)
+ CC="$(CC)" TIMEOUT_BIN="$(TARGET)" sh "$(CURDIR)/tests/test.sh"
+
+status:
+ @printf '%s\n' "$(TARGET)"
+
+clean:
+ @rm -rf "$(OBJDIR)" "$(OUTDIR)"
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000000..27c8b75e7b
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,25 @@
+Copyright (c) 2014 Baptiste Daroussin <bapt@FreeBSD.org>
+Copyright (c) 2014 Vsevolod Stakhov <vsevolod@FreeBSD.org>
+Copyright (c) 2025 Aaron LI <aly@aaronly.me>
+Copyright (c) 2026 Project Tick
+
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions
+are met:
+1. Redistributions of source code must retain the above copyright
+ notice, this list of conditions and the following disclaimer.
+2. Redistributions in binary form must reproduce the above copyright
+ notice, this list of conditions and the following disclaimer in the
+ documentation and/or other materials provided with the distribution.
+
+THIS SOFTWARE IS PROVIDED BY THE REGENTS AND CONTRIBUTORS ``AS IS'' AND
+ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+ARE DISCLAIMED. IN NO EVENT SHALL THE REGENTS OR CONTRIBUTORS BE LIABLE
+FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
+DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS
+OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION)
+HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT
+LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY
+OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF
+SUCH DAMAGE.
diff --git a/README.md b/README.md
new file mode 100644
index 0000000000..3d8448ef87
--- /dev/null
+++ b/README.md
@@ -0,0 +1,47 @@
+# timeout
+
+Standalone Linux-native port of FreeBSD `timeout` for Project Tick BSD/Linux Distribution.
+
+## Build
+
+```sh
+gmake -f GNUmakefile
+gmake -f GNUmakefile CC=musl-gcc
+```
+
+## Test
+
+```sh
+gmake -f GNUmakefile test
+gmake -f GNUmakefile test CC=musl-gcc
+```
+
+## Port Strategy
+
+- Port structure follows sibling standalone ports (`bin/sleep`, `bin/rmdir`, `bin/pwait`): local `GNUmakefile`, short technical `README.md`, shell tests under `tests/`.
+- The FreeBSD source was converted to Linux-native process control instead of carrying BSD-only reaper APIs (`procctl(PROC_REAP_*)`, `str2sig`, `err(3)` helpers).
+- Signal handling was redesigned around blocked-signal waiting (`sigwaitinfo(2)`/`sigtimedwait(2)`) to avoid asynchronous global-flag races and keep strict, deterministic state transitions.
+- Duration and signal parsing are strict and explicit: malformed tokens fail with clear diagnostics and status `125` instead of silent coercion.
+
+## Linux API Mapping
+
+- FreeBSD process reaper control (`procctl(PROC_REAP_ACQUIRE/KILL/STATUS/RELEASE)`) maps to:
+ - `prctl(PR_SET_CHILD_SUBREAPER)` for descendant reparent/wait behavior.
+ - Process-group signaling (`kill(-pgid, sig)`) for default non-foreground propagation.
+ - `waitpid(-1, ..., WNOHANG)` loops to reap both command and adopted descendants.
+- FreeBSD timer path (`setitimer(ITIMER_REAL)` + `SIGALRM`) maps to monotonic deadlines driven by `clock_gettime(CLOCK_MONOTONIC)` and `sigtimedwait(2)` timeout waits.
+- FreeBSD `str2sig(3)` maps to an in-tree, libc-independent signal parser (numbers, `SIG`-prefixed names, aliases, and realtime forms).
+- FreeBSD `err(3)`/`warn(3)` maps to direct `fprintf(3)`/`dprintf(2)` diagnostics, preserving musl portability.
+
+## Supported Semantics On Linux
+
+- `timeout [-f] [-k time] [-p] [-s signal] [-v] duration command [arg ...]` as documented by `timeout.1`.
+- `duration`/`-k time` with `s`, `m`, `h`, `d` suffixes and strict decimal parsing.
+- Exit statuses `124`, `125`, `126`, `127`, plus command status passthrough and self-termination with the command's signal status when required.
+- External `SIGALRM` handling as "time limit reached" behavior, matching `timeout.1`.
+
+## Intentionally Unsupported / Limited Semantics
+
+- Non-finite durations (`inf`, `infinity`, `nan`) are rejected with explicit `125` errors.
+- Descendants that intentionally escape the command process group (e.g. explicit `setsid(2)`/`setpgid(2)` re-grouping) are treated as unsupported during timeout enforcement: if timed-out descendants remain but the original command process group no longer exists, the utility fails explicitly with status `125` instead of silently waiting indefinitely.
+- If Linux subreaper support is unavailable (`prctl(PR_SET_CHILD_SUBREAPER)` missing/failing), non-foreground mode fails explicitly with an error instead of silently downgrading behavior.
diff --git a/tests/test.sh b/tests/test.sh
new file mode 100755
index 0000000000..c9018c50f1
--- /dev/null
+++ b/tests/test.sh
@@ -0,0 +1,285 @@
+#!/bin/sh
+set -eu
+
+ROOT=$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd)
+TIMEOUT_BIN=${TIMEOUT_BIN:-"$ROOT/out/timeout"}
+TMPDIR=${TMPDIR:-/tmp}
+WORKDIR=$(mktemp -d "$TMPDIR/timeout-test.XXXXXX")
+STDOUT_FILE="$WORKDIR/stdout"
+STDERR_FILE="$WORKDIR/stderr"
+LAST_STATUS=0
+LAST_ELAPSED=0
+LAST_STDOUT=
+LAST_STDERR=
+trap 'rm -rf "$WORKDIR"' EXIT INT TERM
+
+export LC_ALL=C
+
+USAGE_TEXT='Usage: timeout [-f | --foreground] [-k time | --kill-after time] [-p | --preserve-status] [-s signal | --signal signal] [-v | --verbose] <duration> <command> [arg ...]'
+
+fail() {
+ printf '%s\n' "FAIL: $1" >&2
+ exit 1
+}
+
+assert_eq() {
+ name=$1
+ expected=$2
+ actual=$3
+ if [ "$expected" != "$actual" ]; then
+ printf '%s\n' "FAIL: $name" >&2
+ printf '%s\n' "--- expected ---" >&2
+ printf '%s\n' "$expected" >&2
+ printf '%s\n' "--- actual ---" >&2
+ printf '%s\n' "$actual" >&2
+ exit 1
+ fi
+}
+
+assert_contains() {
+ name=$1
+ text=$2
+ pattern=$3
+ case $text in
+ *"$pattern"*) ;;
+ *) fail "$name" ;;
+ esac
+}
+
+assert_empty() {
+ name=$1
+ text=$2
+ if [ -n "$text" ]; then
+ printf '%s\n' "FAIL: $name" >&2
+ printf '%s\n' "--- expected empty ---" >&2
+ printf '%s\n' "--- actual ---" >&2
+ printf '%s\n' "$text" >&2
+ exit 1
+ fi
+}
+
+assert_status() {
+ name=$1
+ expected=$2
+ actual=$3
+ if [ "$expected" -ne "$actual" ]; then
+ printf '%s\n' "FAIL: $name" >&2
+ printf '%s\n' "expected status: $expected" >&2
+ printf '%s\n' "actual status: $actual" >&2
+ exit 1
+ fi
+}
+
+assert_ge() {
+ name=$1
+ actual=$2
+ minimum=$3
+ if [ "$actual" -lt "$minimum" ]; then
+ printf '%s\n' "FAIL: $name" >&2
+ printf '%s\n' "expected >= $minimum" >&2
+ printf '%s\n' "actual: $actual" >&2
+ exit 1
+ fi
+}
+
+assert_le() {
+ name=$1
+ actual=$2
+ maximum=$3
+ if [ "$actual" -gt "$maximum" ]; then
+ printf '%s\n' "FAIL: $name" >&2
+ printf '%s\n' "expected <= $maximum" >&2
+ printf '%s\n' "actual: $actual" >&2
+ exit 1
+ fi
+}
+
+run_capture() {
+ if "$@" >"$STDOUT_FILE" 2>"$STDERR_FILE"; then
+ LAST_STATUS=0
+ else
+ LAST_STATUS=$?
+ fi
+ LAST_STDOUT=$(cat "$STDOUT_FILE")
+ LAST_STDERR=$(cat "$STDERR_FILE")
+}
+
+run_timed_capture() {
+ start=$(date +%s)
+ if "$@" >"$STDOUT_FILE" 2>"$STDERR_FILE"; then
+ LAST_STATUS=0
+ else
+ LAST_STATUS=$?
+ fi
+ end=$(date +%s)
+ LAST_ELAPSED=$((end - start))
+ LAST_STDOUT=$(cat "$STDOUT_FILE")
+ LAST_STDERR=$(cat "$STDERR_FILE")
+}
+
+[ -x "$TIMEOUT_BIN" ] || fail "missing binary: $TIMEOUT_BIN"
+
+run_capture "$TIMEOUT_BIN"
+assert_status "usage status" 125 "$LAST_STATUS"
+assert_empty "usage stdout" "$LAST_STDOUT"
+assert_eq "usage stderr" "$USAGE_TEXT" "$LAST_STDERR"
+
+run_capture "$TIMEOUT_BIN" -z
+assert_status "invalid option status" 125 "$LAST_STATUS"
+assert_empty "invalid option stdout" "$LAST_STDOUT"
+assert_eq "invalid option stderr" "$USAGE_TEXT" "$LAST_STDERR"
+
+run_capture "$TIMEOUT_BIN" -- 1 sh -c 'exit 0'
+assert_status "double-dash separator status" 0 "$LAST_STATUS"
+assert_empty "double-dash separator stdout" "$LAST_STDOUT"
+assert_empty "double-dash separator stderr" "$LAST_STDERR"
+
+run_capture "$TIMEOUT_BIN" invalid sleep 0
+assert_status "invalid duration token status" 125 "$LAST_STATUS"
+assert_empty "invalid duration token stdout" "$LAST_STDOUT"
+assert_eq "invalid duration token stderr" \
+ "timeout: duration is not a number" "$LAST_STDERR"
+
+run_capture "$TIMEOUT_BIN" -k invalid 1 sleep 0
+assert_status "invalid kill-after status" 125 "$LAST_STATUS"
+assert_empty "invalid kill-after stdout" "$LAST_STDOUT"
+assert_eq "invalid kill-after stderr" \
+ "timeout: duration is not a number" "$LAST_STDERR"
+
+run_capture "$TIMEOUT_BIN" 42D sleep 0
+assert_status "invalid duration unit status" 125 "$LAST_STATUS"
+assert_empty "invalid duration unit stdout" "$LAST_STDOUT"
+assert_eq "invalid duration unit stderr" \
+ "timeout: duration unit suffix invalid" "$LAST_STDERR"
+
+run_capture "$TIMEOUT_BIN" 1ss sleep 0
+assert_status "duration suffix too long status" 125 "$LAST_STATUS"
+assert_empty "duration suffix too long stdout" "$LAST_STDOUT"
+assert_eq "duration suffix too long stderr" \
+ "timeout: duration unit suffix too long" "$LAST_STDERR"
+
+run_capture "$TIMEOUT_BIN" -- -1 sleep 0
+assert_status "negative duration status" 125 "$LAST_STATUS"
+assert_empty "negative duration stdout" "$LAST_STDOUT"
+assert_eq "negative duration stderr" \
+ "timeout: duration out of range" "$LAST_STDERR"
+
+run_capture "$TIMEOUT_BIN" inf sleep 0
+assert_status "non-finite duration status" 125 "$LAST_STATUS"
+assert_empty "non-finite duration stdout" "$LAST_STDOUT"
+assert_eq "non-finite duration stderr" \
+ "timeout: duration out of range" "$LAST_STDERR"
+
+run_capture "$TIMEOUT_BIN" -s not-a-signal 1 sleep 0
+assert_status "invalid signal status" 125 "$LAST_STATUS"
+assert_empty "invalid signal stdout" "$LAST_STDOUT"
+assert_eq "invalid signal stderr" \
+ "timeout: invalid signal: not-a-signal" "$LAST_STDERR"
+
+run_capture "$TIMEOUT_BIN" 1 sh -c 'exit 7'
+assert_status "normal exit passthrough status" 7 "$LAST_STATUS"
+assert_empty "normal exit passthrough stdout" "$LAST_STDOUT"
+assert_empty "normal exit passthrough stderr" "$LAST_STDERR"
+
+run_capture "$TIMEOUT_BIN" 0 sh -c 'exit 23'
+assert_status "zero duration status passthrough status" 23 "$LAST_STATUS"
+assert_empty "zero duration status passthrough stdout" "$LAST_STDOUT"
+assert_empty "zero duration status passthrough stderr" "$LAST_STDERR"
+
+run_capture "$TIMEOUT_BIN" 1 sleep 5
+assert_status "timeout status" 124 "$LAST_STATUS"
+assert_empty "timeout stdout" "$LAST_STDOUT"
+assert_empty "timeout stderr" "$LAST_STDERR"
+
+run_capture "$TIMEOUT_BIN" -v 1 sleep 5
+assert_status "verbose timeout status" 124 "$LAST_STATUS"
+assert_empty "verbose timeout stdout" "$LAST_STDOUT"
+assert_contains "verbose timeout stderr marker" "$LAST_STDERR" "time limit reached"
+
+run_capture "$TIMEOUT_BIN" -p 1 sleep 5
+assert_status "preserve timeout status" 143 "$LAST_STATUS"
+assert_empty "preserve timeout stdout" "$LAST_STDOUT"
+assert_empty "preserve timeout stderr" "$LAST_STDERR"
+
+run_capture "$TIMEOUT_BIN" -p -s KILL 1 sleep 5
+assert_status "preserve timeout SIGKILL status" 137 "$LAST_STATUS"
+assert_empty "preserve timeout SIGKILL stdout" "$LAST_STDOUT"
+assert_empty "preserve timeout SIGKILL stderr" "$LAST_STDERR"
+
+run_capture "$TIMEOUT_BIN" -p -k 1 1 sh -c 'trap "" TERM; sleep 10'
+assert_status "kill-after escalation status" 137 "$LAST_STATUS"
+assert_empty "kill-after escalation stdout" "$LAST_STDOUT"
+assert_empty "kill-after escalation stderr" "$LAST_STDERR"
+
+NOEXEC="$WORKDIR/noexec.sh"
+printf '%s\n' '#!/bin/sh' 'exit 0' >"$NOEXEC"
+chmod 0644 "$NOEXEC"
+run_capture "$TIMEOUT_BIN" 1 "$NOEXEC"
+assert_status "non-executable command status" 126 "$LAST_STATUS"
+assert_empty "non-executable command stdout" "$LAST_STDOUT"
+assert_contains "non-executable command stderr path" "$LAST_STDERR" "exec($NOEXEC)"
+
+run_capture "$TIMEOUT_BIN" 1 does-not-exist-timeout-test-cmd
+assert_status "missing command status" 127 "$LAST_STATUS"
+assert_empty "missing command stdout" "$LAST_STDOUT"
+assert_contains "missing command stderr cmd" "$LAST_STDERR" "exec(does-not-exist-timeout-test-cmd)"
+
+"$TIMEOUT_BIN" 30 sleep 10 >"$STDOUT_FILE" 2>"$STDERR_FILE" &
+bg_timeout_pid=$!
+sleep 1
+kill -ALRM "$bg_timeout_pid"
+if wait "$bg_timeout_pid"; then
+ bg_timeout_status=0
+else
+ bg_timeout_status=$?
+fi
+assert_status "SIGALRM external timeout status" 124 "$bg_timeout_status"
+assert_empty "SIGALRM external timeout stdout" "$(cat "$STDOUT_FILE")"
+assert_empty "SIGALRM external timeout stderr" "$(cat "$STDERR_FILE")"
+
+"$TIMEOUT_BIN" 30 sh -c 'trap "exit 77" USR1; while :; do sleep 1; done' \
+ >"$STDOUT_FILE" 2>"$STDERR_FILE" &
+prop_pid=$!
+sleep 1
+kill -USR1 "$prop_pid"
+if wait "$prop_pid"; then
+ prop_status=0
+else
+ prop_status=$?
+fi
+assert_status "signal propagation status" 77 "$prop_status"
+assert_empty "signal propagation stdout" "$(cat "$STDOUT_FILE")"
+assert_empty "signal propagation stderr" "$(cat "$STDERR_FILE")"
+
+PIDFILE="$WORKDIR/desc.pid"
+run_timed_capture \
+ "$TIMEOUT_BIN" -s INT 1 \
+ sh -c '(trap "" INT HUP; sleep 5) & echo $! >"$1"; sleep 10' \
+ sh "$PIDFILE"
+assert_status "non-foreground descendant wait status" 124 "$LAST_STATUS"
+assert_ge "non-foreground descendant wait elapsed seconds" "$LAST_ELAPSED" 4
+assert_empty "non-foreground descendant wait stdout" "$LAST_STDOUT"
+assert_empty "non-foreground descendant wait stderr" "$LAST_STDERR"
+[ -s "$PIDFILE" ] || fail "missing descendant pid for non-foreground case"
+desc_pid=$(cat "$PIDFILE")
+if kill -0 "$desc_pid" 2>/dev/null; then
+ fail "descendant still alive after non-foreground timeout"
+fi
+
+rm -f "$PIDFILE"
+run_timed_capture \
+ "$TIMEOUT_BIN" -f -s INT 1 \
+ sh -c '(trap "" INT HUP; sleep 5) & echo $! >"$1"; sleep 10' \
+ sh "$PIDFILE"
+assert_status "foreground timeout status" 124 "$LAST_STATUS"
+assert_le "foreground timeout elapsed seconds" "$LAST_ELAPSED" 3
+assert_empty "foreground timeout stdout" "$LAST_STDOUT"
+assert_empty "foreground timeout stderr" "$LAST_STDERR"
+[ -s "$PIDFILE" ] || fail "missing descendant pid for foreground case"
+desc_pid=$(cat "$PIDFILE")
+if ! kill -0 "$desc_pid" 2>/dev/null; then
+ fail "descendant not alive after foreground timeout"
+fi
+kill -KILL "$desc_pid" 2>/dev/null || true
+
+printf '%s\n' "PASS"
diff --git a/timeout.1 b/timeout.1
new file mode 100644
index 0000000000..0a9754a2cc
--- /dev/null
+++ b/timeout.1
@@ -0,0 +1,292 @@
+.\" SPDX-License-Identifier: BSD-2-Clause
+.\"
+.\" Copyright (c) 2014 Baptiste Daroussin <bapt@FreeBSD.org>
+.\" Copyright (c) 2025 Aaron LI <aly@aaronly.me>
+.\" All rights reserved.
+.\"
+.\" Redistribution and use in source and binary forms, with or without
+.\" modification, are permitted provided that the following conditions
+.\" are met:
+.\" 1. Redistributions of source code must retain the above copyright
+.\" notice, this list of conditions and the following disclaimer.
+.\" 2. Redistributions in binary form must reproduce the above copyright
+.\" notice, this list of conditions and the following disclaimer in the
+.\" documentation and/or other materials provided with the distribution.
+.\"
+.\" THIS SOFTWARE IS PROVIDED BY THE AUTHOR AND CONTRIBUTORS ``AS IS'' AND
+.\" ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+.\" IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+.\" ARE DISCLAIMED. IN NO EVENT SHALL THE AUTHOR OR CONTRIBUTORS BE LIABLE
+.\" FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
+.\" DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS
+.\" OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION)
+.\" HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT
+.\" LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY
+.\" OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF
+.\" SUCH DAMAGE.
+.\"
+.Dd April 3, 2025
+.Dt TIMEOUT 1
+.Os
+.Sh NAME
+.Nm timeout
+.Nd run a command with a time limit
+.Sh SYNOPSIS
+.Nm
+.Op Fl f | Fl -foreground
+.Op Fl k Ar time | Fl -kill-after Ar time
+.Op Fl p | Fl -preserve-status
+.Op Fl s Ar signal | Fl -signal Ar signal
+.Op Fl v | Fl -verbose
+.Ar duration
+.Ar command
+.Op Ar arg ...
+.Sh DESCRIPTION
+.Nm Timeout
+starts the
+.Ar command
+with its
+.Ar arg
+list.
+If the
+.Ar command
+is still running after
+.Ar duration ,
+it is killed by sending the
+.Ar signal ,
+or
+.Dv SIGTERM
+if the
+.Fl s
+option is unspecified.
+The special
+.Ar duration ,
+zero, signifies no limit.
+Therefore, a signal is never sent if
+.Ar duration
+is 0.
+.Pp
+The signal dispositions inherited by the
+.Ar command
+are the same as the dispositions that
+.Nm
+inherited, except for the signal that will be sent upon timeout,
+which is reset to take the default action and should terminate
+the process.
+.Pp
+If
+.Nm
+receives the
+.Dv SIGALRM
+signal, it will behave as if the time limit has been reached
+and send the specified signal to
+.Ar command .
+For any other signals delivered to
+.Nm ,
+it will propagate them to
+.Ar command ,
+with the exception of
+.Dv SIGKILL
+and
+.Dv SIGSTOP .
+If you want to prevent the
+.Ar command
+from being timed out, send
+.Dv SIGKILL
+to
+.Nm .
+.Pp
+The options are as follows:
+.Bl -tag -width indent
+.It Fl f , Fl -foreground
+Only time out the
+.Ar command
+itself, but do not propagate signals to its descendants.
+See the
+.Sx IMPLEMENTATION NOTES
+section for more details.
+.It Fl k Ar time , Fl -kill-after Ar time
+Send a
+.Dv SIGKILL
+signal if
+.Ar command
+is still running after
+.Ar time
+since the first signal was sent.
+.It Fl p , Fl -preserve-status
+Always exit with the same status as
+.Ar command ,
+even if the timeout was reached.
+.It Fl s Ar signal , Fl -signal Ar signal
+Specify the signal to send on timeout.
+By default,
+.Dv SIGTERM
+is sent.
+.It Fl v , Fl -verbose
+Show information to
+.Xr stderr 4
+about timeouts, signals to be sent, and the
+.Ar command
+exits.
+.El
+.Ss Duration Format
+The
+.Ar duration
+and
+.Ar time
+are non-negative integer or real (decimal) numbers, with an optional
+suffix specifying the unit.
+Values without an explicit unit are interpreted as seconds.
+.Pp
+Supported unit suffixes are:
+.Bl -tag -offset indent -width indent -compact
+.It Cm s
+seconds
+.It Cm m
+minutes
+.It Cm h
+hours
+.It Cm d
+days
+.El
+.Sh IMPLEMENTATION NOTES
+If the
+.Fl -foreground
+option is not specified,
+.Nm
+runs as the reaper (see also
+.Xr procctl 2 )
+of the
+.Ar command
+and its descendants, and will wait for all the descendants to terminate.
+This behavior might cause surprises if there are descendants running
+in the background, because they will ignore
+.Dv SIGINT
+and
+.Dv SIGQUIT
+signals.
+For example, the following command that sends a
+.Dv SIGTERM
+signal will complete in 2 seconds:
+.Dl $ timeout -s TERM 2 sh -c 'sleep 4 & sleep 5'
+However, this command that sends a
+.Dv SIGINT
+signal will complete in 4 seconds:
+.Dl $ timeout -s INT 2 sh -c 'sleep 4 & sleep 5'
+.Sh EXIT STATUS
+If the time limit was reached and the
+.Fl -preserve-status
+option is not specified, the exit status is 124.
+Otherwise,
+.Nm
+exits with the same exit status as the
+.Ar command .
+For example,
+.Nm
+will terminate itself with the same signal if the
+.Ar command
+is terminated by a signal.
+.Pp
+If an error occurred, the following exit values are returned:
+.Bl -tag -offset indent -width indent -compact
+.It 125
+An error other than the two described below occurred.
+For example, an invalid duration or signal was specified.
+.It 126
+The
+.Ar command
+was found but could not be executed.
+.It 127
+The
+.Ar command
+could not be found.
+.El
+.Sh EXAMPLES
+Run
+.Xr sleep 1
+with a time limit of 4 seconds.
+Since the command completes in 2 seconds, the exit status is 0:
+.Bd -literal -offset indent
+$ timeout 4 sleep 2
+$ echo $?
+0
+.Ed
+.Pp
+Run
+.Xr sleep 1
+for 4 seconds and terminate process after 2 seconds.
+The exit status is 124 since
+.Fl -preserve-status
+is not used:
+.Bd -literal -offset indent
+$ timeout 2 sleep 4
+$ echo $?
+124
+.Ed
+.Pp
+Same as above but preserving status.
+The exit status is 128 + signal number (15 for
+.Dv SIGTERM )
+for most shells:
+.Bd -literal -offset indent
+$ timeout --preserve-status 2 sleep 4
+$ echo $?
+143
+.Ed
+.Pp
+Same as above but sending
+.Dv SIGALRM
+(signal number 14) instead of
+.Dv SIGTERM :
+.Bd -literal -offset indent
+$ timeout --preserve-status -s SIGALRM 2 sleep 4
+$ echo $?
+142
+.Ed
+.Pp
+Try to
+.Xr fetch 1
+the PDF version of the
+.Fx
+Handbook.
+Send a
+.Dv SIGTERM
+signal after 1 minute and send a
+.Dv SIGKILL
+signal 5 seconds later if the process refuses to stop:
+.Bd -literal -offset indent
+$ timeout -k 5s 1m fetch \\
+> https://download.freebsd.org/ftp/doc/en/books/handbook/book.pdf
+.Ed
+.Sh SEE ALSO
+.Xr kill 1 ,
+.Xr nohup 1 ,
+.Xr signal 3 ,
+.Xr daemon 8
+.Sh STANDARDS
+The
+.Nm
+utility is expected to conform to the
+.St -p1003.1-2024
+specification.
+.Sh HISTORY
+The
+.Nm
+command first appeared in
+.Fx 10.3 .
+.Pp
+The initial
+.Fx
+work was compatible with GNU
+.Nm
+by
+.An Padraig Brady ,
+from GNU Coreutils 8.21.
+The
+.Nm
+utility first appeared in GNU Coreutils 7.0.
+.Sh AUTHORS
+.An Baptiste Daroussin Aq Mt bapt@FreeBSD.org ,
+.An Vsevolod Stakhov Aq Mt vsevolod@FreeBSD.org
+and
+.An Aaron LI Aq Mt aly@aaronly.me
diff --git a/timeout.c b/timeout.c
new file mode 100644
index 0000000000..bcb56f978c
--- /dev/null
+++ b/timeout.c
@@ -0,0 +1,1016 @@
+/*-
+ * SPDX-License-Identifier: BSD-2-Clause
+ *
+ * Copyright (c) 2014 Baptiste Daroussin <bapt@FreeBSD.org>
+ * Copyright (c) 2014 Vsevolod Stakhov <vsevolod@FreeBSD.org>
+ * Copyright (c) 2025 Aaron LI <aly@aaronly.me>
+ * Copyright (c) 2026 Project Tick
+ * All rights reserved.
+ *
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions
+ * are met:
+ * 1. Redistributions of source code must retain the above copyright
+ * notice, this list of conditions and the following disclaimer
+ * in this position and unchanged.
+ * 2. Redistributions in binary form must reproduce the above copyright
+ * notice, this list of conditions and the following disclaimer in the
+ * documentation and/or other materials provided with the distribution.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE AUTHOR(S) ``AS IS'' AND ANY EXPRESS OR
+ * IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES
+ * OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED.
+ * IN NO EVENT SHALL THE AUTHOR(S) BE LIABLE FOR ANY DIRECT, INDIRECT,
+ * INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT
+ * NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+ * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+ * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+ * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF
+ * THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ */
+
+#define _POSIX_C_SOURCE 200809L
+
+#include <ctype.h>
+#include <errno.h>
+#include <getopt.h>
+#include <inttypes.h>
+#include <limits.h>
+#include <math.h>
+#include <signal.h>
+#include <stdarg.h>
+#include <stdbool.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <sys/prctl.h>
+#include <sys/resource.h>
+#include <sys/types.h>
+#include <sys/wait.h>
+#include <time.h>
+#include <unistd.h>
+
+#define EXIT_TIMEOUT 124
+#define EXIT_INVALID 125
+#define EXIT_CMD_ERROR 126
+#define EXIT_CMD_NOENT 127
+
+#ifndef NSIG
+#define NSIG 128
+#endif
+
+struct signal_entry {
+ const char *name;
+ int number;
+};
+
+#define SIGNAL_ENTRY(name) { #name, SIG##name }
+#define SIGNAL_ALIAS(name, signal) { name, signal }
+
+static const struct signal_entry canonical_signals[] = {
+#ifdef SIGHUP
+ SIGNAL_ENTRY(HUP),
+#endif
+#ifdef SIGINT
+ SIGNAL_ENTRY(INT),
+#endif
+#ifdef SIGQUIT
+ SIGNAL_ENTRY(QUIT),
+#endif
+#ifdef SIGILL
+ SIGNAL_ENTRY(ILL),
+#endif
+#ifdef SIGTRAP
+ SIGNAL_ENTRY(TRAP),
+#endif
+#ifdef SIGABRT
+ SIGNAL_ENTRY(ABRT),
+#endif
+#ifdef SIGBUS
+ SIGNAL_ENTRY(BUS),
+#endif
+#ifdef SIGFPE
+ SIGNAL_ENTRY(FPE),
+#endif
+#ifdef SIGKILL
+ SIGNAL_ENTRY(KILL),
+#endif
+#ifdef SIGUSR1
+ SIGNAL_ENTRY(USR1),
+#endif
+#ifdef SIGSEGV
+ SIGNAL_ENTRY(SEGV),
+#endif
+#ifdef SIGUSR2
+ SIGNAL_ENTRY(USR2),
+#endif
+#ifdef SIGPIPE
+ SIGNAL_ENTRY(PIPE),
+#endif
+#ifdef SIGALRM
+ SIGNAL_ENTRY(ALRM),
+#endif
+#ifdef SIGTERM
+ SIGNAL_ENTRY(TERM),
+#endif
+#ifdef SIGSTKFLT
+ SIGNAL_ENTRY(STKFLT),
+#endif
+#ifdef SIGCHLD
+ SIGNAL_ENTRY(CHLD),
+#endif
+#ifdef SIGCONT
+ SIGNAL_ENTRY(CONT),
+#endif
+#ifdef SIGSTOP
+ SIGNAL_ENTRY(STOP),
+#endif
+#ifdef SIGTSTP
+ SIGNAL_ENTRY(TSTP),
+#endif
+#ifdef SIGTTIN
+ SIGNAL_ENTRY(TTIN),
+#endif
+#ifdef SIGTTOU
+ SIGNAL_ENTRY(TTOU),
+#endif
+#ifdef SIGURG
+ SIGNAL_ENTRY(URG),
+#endif
+#ifdef SIGXCPU
+ SIGNAL_ENTRY(XCPU),
+#endif
+#ifdef SIGXFSZ
+ SIGNAL_ENTRY(XFSZ),
+#endif
+#ifdef SIGVTALRM
+ SIGNAL_ENTRY(VTALRM),
+#endif
+#ifdef SIGPROF
+ SIGNAL_ENTRY(PROF),
+#endif
+#ifdef SIGWINCH
+ SIGNAL_ENTRY(WINCH),
+#endif
+#ifdef SIGIO
+ SIGNAL_ENTRY(IO),
+#endif
+#ifdef SIGPWR
+ SIGNAL_ENTRY(PWR),
+#endif
+#ifdef SIGSYS
+ SIGNAL_ENTRY(SYS),
+#endif
+};
+
+static const struct signal_entry signal_aliases[] = {
+#ifdef SIGIOT
+ SIGNAL_ALIAS("IOT", SIGIOT),
+#endif
+#ifdef SIGCLD
+ SIGNAL_ALIAS("CLD", SIGCLD),
+#endif
+#ifdef SIGPOLL
+ SIGNAL_ALIAS("POLL", SIGPOLL),
+#endif
+#ifdef SIGUNUSED
+ SIGNAL_ALIAS("UNUSED", SIGUNUSED),
+#endif
+};
+
+struct options {
+ bool foreground;
+ bool preserve;
+ bool verbose;
+ bool kill_after_set;
+ int timeout_signal;
+ long double duration;
+ long double kill_after;
+ const char *command_name;
+ char **command_argv;
+};
+
+struct child_state {
+ bool reaped;
+ int status;
+};
+
+enum deadline_kind {
+ DEADLINE_NONE = 0,
+ DEADLINE_FIRST,
+ DEADLINE_SECOND,
+};
+
+struct runtime_state {
+ bool timed_out;
+ bool first_timer_active;
+ bool second_timer_active;
+ bool second_pending;
+ long double first_deadline;
+ long double second_deadline;
+ int active_signal;
+};
+
+static const char *program_name = "timeout";
+
+static void usage(void) __attribute__((noreturn));
+static void die(int status, const char *fmt, ...)
+ __attribute__((noreturn, format(printf, 2, 3)));
+static void die_errno(int status, const char *context) __attribute__((noreturn));
+static void vlog(const struct options *opt, const char *fmt, ...)
+ __attribute__((format(printf, 2, 3)));
+static const char *basename_const(const char *path);
+static bool parse_nonnegative_int(const char *text, int *value);
+static char *normalize_signal_name(const char *token);
+static bool signal_name_for_number(int signum, char *buffer, size_t buffer_size);
+static bool parse_signal_name(const char *token, int *signum);
+static bool parse_signal_token(const char *token, int *signum);
+static long double parse_duration_or_die(const char *text);
+static long double monotonic_seconds(void);
+static long double max_time_t_seconds(void);
+static struct timespec seconds_to_timeout(long double seconds);
+static enum deadline_kind next_deadline(const struct runtime_state *state,
+ struct timespec *timeout);
+static void enable_subreaper_or_die(void);
+static bool is_terminating_signal(int signo);
+static void send_signal_to_command(pid_t pid, int signo, const struct options *opt);
+static void arm_second_timer(struct runtime_state *state, const struct options *opt);
+static void reap_children(pid_t command_pid, struct child_state *child,
+ bool *have_children, const struct options *opt);
+static void child_exec(const struct options *opt, const sigset_t *oldmask)
+ __attribute__((noreturn));
+static void kill_self(int signo) __attribute__((noreturn));
+static bool process_group_exists(pid_t pgid);
+
+static void
+usage(void)
+{
+ fprintf(stderr,
+ "Usage: %s [-f | --foreground] [-k time | --kill-after time]"
+ " [-p | --preserve-status] [-s signal | --signal signal] "
+ "[-v | --verbose] <duration> <command> [arg ...]\n",
+ program_name);
+ exit(EXIT_INVALID);
+}
+
+static void
+die(int status, const char *fmt, ...)
+{
+ va_list ap;
+
+ fprintf(stderr, "%s: ", program_name);
+ va_start(ap, fmt);
+ vfprintf(stderr, fmt, ap);
+ va_end(ap);
+ fputc('\n', stderr);
+ exit(status);
+}
+
+static void
+die_errno(int status, const char *context)
+{
+ die(status, "%s: %s", context, strerror(errno));
+}
+
+static void
+vlog(const struct options *opt, const char *fmt, ...)
+{
+ va_list ap;
+
+ if (!opt->verbose)
+ return;
+
+ fprintf(stderr, "%s: ", program_name);
+ va_start(ap, fmt);
+ vfprintf(stderr, fmt, ap);
+ va_end(ap);
+ fputc('\n', stderr);
+}
+
+static const char *
+basename_const(const char *path)
+{
+ const char *base;
+
+ base = strrchr(path, '/');
+ if (base == NULL || base[1] == '\0')
+ return (path);
+ return (base + 1);
+}
+
+static bool
+parse_nonnegative_int(const char *text, int *value)
+{
+ intmax_t parsed;
+ char *end;
+
+ if (text[0] == '\0')
+ return (false);
+
+ errno = 0;
+ parsed = strtoimax(text, &end, 10);
+ if (errno == ERANGE || *end != '\0' || parsed < 0 || parsed > INT_MAX)
+ return (false);
+ *value = (int)parsed;
+ return (true);
+}
+
+static char *
+normalize_signal_name(const char *token)
+{
+ size_t i, length, offset;
+ char *name;
+
+ length = strlen(token);
+ name = malloc(length + 1);
+ if (name == NULL)
+ die(EXIT_INVALID, "out of memory");
+
+ for (i = 0; i < length; i++)
+ name[i] = (char)toupper((unsigned char)token[i]);
+ name[length] = '\0';
+
+ offset = 0;
+ if (length > 3 && name[0] == 'S' && name[1] == 'I' && name[2] == 'G')
+ offset = 3;
+ if (offset != 0)
+ memmove(name, name + offset, length - offset + 1);
+ return (name);
+}
+
+static bool
+signal_name_for_number(int signum, char *buffer, size_t buffer_size)
+{
+ size_t i;
+
+ if (signum == 0) {
+ if (buffer != NULL && buffer_size > 0) {
+ buffer[0] = '0';
+ if (buffer_size > 1)
+ buffer[1] = '\0';
+ }
+ return (true);
+ }
+
+ for (i = 0; i < sizeof(canonical_signals) / sizeof(canonical_signals[0]); i++) {
+ if (canonical_signals[i].number != signum)
+ continue;
+ if (buffer != NULL && buffer_size > 0) {
+ if (snprintf(buffer, buffer_size, "%s",
+ canonical_signals[i].name) >= (int)buffer_size)
+ die(EXIT_INVALID, "internal signal name overflow");
+ }
+ return (true);
+ }
+
+#ifdef SIGRTMIN
+#ifdef SIGRTMAX
+ if (signum == SIGRTMIN) {
+ if (buffer != NULL && buffer_size > 0)
+ snprintf(buffer, buffer_size, "RTMIN");
+ return (true);
+ }
+ if (signum == SIGRTMAX) {
+ if (buffer != NULL && buffer_size > 0)
+ snprintf(buffer, buffer_size, "RTMAX");
+ return (true);
+ }
+ if (signum > SIGRTMIN && signum < SIGRTMAX) {
+ if (buffer != NULL && buffer_size > 0) {
+ if (snprintf(buffer, buffer_size, "RTMIN+%d",
+ signum - SIGRTMIN) >= (int)buffer_size)
+ die(EXIT_INVALID, "internal signal name overflow");
+ }
+ return (true);
+ }
+#endif
+#endif
+
+ return (false);
+}
+
+static bool
+parse_signal_name(const char *token, int *signum)
+{
+ char *name, *end;
+ int offset;
+ size_t i;
+
+ if (strcmp(token, "0") == 0) {
+ *signum = 0;
+ return (true);
+ }
+
+ name = normalize_signal_name(token);
+
+ for (i = 0; i < sizeof(canonical_signals) / sizeof(canonical_signals[0]); i++) {
+ if (strcmp(name, canonical_signals[i].name) == 0) {
+ *signum = canonical_signals[i].number;
+ free(name);
+ return (true);
+ }
+ }
+ for (i = 0; i < sizeof(signal_aliases) / sizeof(signal_aliases[0]); i++) {
+ if (strcmp(name, signal_aliases[i].name) == 0) {
+ *signum = signal_aliases[i].number;
+ free(name);
+ return (true);
+ }
+ }
+
+#ifdef SIGRTMIN
+#ifdef SIGRTMAX
+ if (strcmp(name, "RTMIN") == 0) {
+ *signum = SIGRTMIN;
+ free(name);
+ return (true);
+ }
+ if (strncmp(name, "RTMIN+", 6) == 0) {
+ errno = 0;
+ offset = (int)strtol(name + 6, &end, 10);
+ if (errno == 0 && *end == '\0' && offset >= 0 &&
+ SIGRTMIN + offset <= SIGRTMAX) {
+ *signum = SIGRTMIN + offset;
+ free(name);
+ return (true);
+ }
+ }
+ if (strcmp(name, "RTMAX") == 0) {
+ *signum = SIGRTMAX;
+ free(name);
+ return (true);
+ }
+ if (strncmp(name, "RTMAX-", 6) == 0) {
+ errno = 0;
+ offset = (int)strtol(name + 6, &end, 10);
+ if (errno == 0 && *end == '\0' && offset >= 0 &&
+ SIGRTMAX - offset >= SIGRTMIN) {
+ *signum = SIGRTMAX - offset;
+ free(name);
+ return (true);
+ }
+ }
+#endif
+#endif
+
+ free(name);
+ return (false);
+}
+
+static bool
+parse_signal_token(const char *token, int *signum)
+{
+ if (parse_nonnegative_int(token, signum)) {
+ if (*signum >= NSIG)
+ return (false);
+ return (true);
+ }
+ return (parse_signal_name(token, signum));
+}
+
+static long double
+parse_duration_or_die(const char *text)
+{
+ long double value;
+ char *end;
+
+ if (text[0] == '\0' || isspace((unsigned char)text[0]))
+ die(EXIT_INVALID, "duration is not a number");
+
+ errno = 0;
+ value = strtold(text, &end);
+ if (end == text)
+ die(EXIT_INVALID, "duration is not a number");
+ if (errno == ERANGE || !isfinite(value))
+ die(EXIT_INVALID, "duration out of range");
+ if (*end != '\0') {
+ if (end[1] != '\0')
+ die(EXIT_INVALID, "duration unit suffix too long");
+ switch (*end) {
+ case 's':
+ break;
+ case 'm':
+ value *= 60.0L;
+ break;
+ case 'h':
+ value *= 3600.0L;
+ break;
+ case 'd':
+ value *= 86400.0L;
+ break;
+ default:
+ die(EXIT_INVALID, "duration unit suffix invalid");
+ }
+ }
+ if (!isfinite(value) || value < 0.0L)
+ die(EXIT_INVALID, "duration out of range");
+ return (value);
+}
+
+static long double
+monotonic_seconds(void)
+{
+ struct timespec ts;
+
+ if (clock_gettime(CLOCK_MONOTONIC, &ts) != 0)
+ die_errno(EXIT_INVALID, "clock_gettime(CLOCK_MONOTONIC)");
+
+ return ((long double)ts.tv_sec +
+ (long double)ts.tv_nsec / 1000000000.0L);
+}
+
+static long double
+max_time_t_seconds(void)
+{
+ int bits;
+
+ bits = (int)(sizeof(time_t) * CHAR_BIT);
+ if ((time_t)-1 < (time_t)0)
+ return (ldexpl(1.0L, bits - 1) - 1.0L);
+ return (ldexpl(1.0L, bits) - 1.0L);
+}
+
+static struct timespec
+seconds_to_timeout(long double seconds)
+{
+ struct timespec ts;
+ long double whole, fractional, max_seconds;
+
+ if (seconds <= 0.0L) {
+ ts.tv_sec = 0;
+ ts.tv_nsec = 0;
+ return (ts);
+ }
+
+ max_seconds = max_time_t_seconds();
+ if (seconds > max_seconds)
+ seconds = max_seconds;
+
+ fractional = modfl(seconds, &whole);
+ ts.tv_sec = (time_t)whole;
+ ts.tv_nsec = (long)(fractional * 1000000000.0L);
+ if (ts.tv_nsec < 0)
+ ts.tv_nsec = 0;
+ if (ts.tv_nsec >= 1000000000L)
+ ts.tv_nsec = 999999999L;
+ return (ts);
+}
+
+static enum deadline_kind
+next_deadline(const struct runtime_state *state, struct timespec *timeout)
+{
+ long double now, best, remaining;
+ enum deadline_kind kind;
+
+ now = monotonic_seconds();
+ best = 0.0L;
+ kind = DEADLINE_NONE;
+
+ if (state->first_timer_active) {
+ remaining = state->first_deadline - now;
+ if (remaining < 0.0L)
+ remaining = 0.0L;
+ best = remaining;
+ kind = DEADLINE_FIRST;
+ }
+ if (state->second_timer_active) {
+ remaining = state->second_deadline - now;
+ if (remaining < 0.0L)
+ remaining = 0.0L;
+ if (kind == DEADLINE_NONE || remaining < best) {
+ best = remaining;
+ kind = DEADLINE_SECOND;
+ }
+ }
+
+ if (kind != DEADLINE_NONE)
+ *timeout = seconds_to_timeout(best);
+ return (kind);
+}
+
+static void
+enable_subreaper_or_die(void)
+{
+#ifdef PR_SET_CHILD_SUBREAPER
+ if (prctl(PR_SET_CHILD_SUBREAPER, 1L, 0L, 0L, 0L) != 0)
+ die_errno(EXIT_INVALID, "prctl(PR_SET_CHILD_SUBREAPER)");
+#else
+ die(EXIT_INVALID,
+ "Linux child subreaper API is unavailable; use -f/--foreground");
+#endif
+}
+
+static bool
+is_terminating_signal(int signo)
+{
+ switch (signo) {
+#ifdef SIGHUP
+ case SIGHUP:
+#endif
+#ifdef SIGINT
+ case SIGINT:
+#endif
+#ifdef SIGQUIT
+ case SIGQUIT:
+#endif
+#ifdef SIGILL
+ case SIGILL:
+#endif
+#ifdef SIGTRAP
+ case SIGTRAP:
+#endif
+#ifdef SIGABRT
+ case SIGABRT:
+#endif
+#ifdef SIGBUS
+ case SIGBUS:
+#endif
+#ifdef SIGFPE
+ case SIGFPE:
+#endif
+#ifdef SIGSEGV
+ case SIGSEGV:
+#endif
+#ifdef SIGPIPE
+ case SIGPIPE:
+#endif
+#ifdef SIGTERM
+ case SIGTERM:
+#endif
+#ifdef SIGXCPU
+ case SIGXCPU:
+#endif
+#ifdef SIGXFSZ
+ case SIGXFSZ:
+#endif
+#ifdef SIGVTALRM
+ case SIGVTALRM:
+#endif
+#ifdef SIGPROF
+ case SIGPROF:
+#endif
+#ifdef SIGUSR1
+ case SIGUSR1:
+#endif
+#ifdef SIGUSR2
+ case SIGUSR2:
+#endif
+#ifdef SIGSYS
+ case SIGSYS:
+#endif
+ return (true);
+ default:
+ return (false);
+ }
+}
+
+static void
+send_signal_to_command(pid_t pid, int signo, const struct options *opt)
+{
+ char sigbuf[32];
+ const char *signame;
+ pid_t target;
+
+ if (!signal_name_for_number(signo, sigbuf, sizeof(sigbuf)))
+ snprintf(sigbuf, sizeof(sigbuf), "%d", signo);
+ signame = sigbuf;
+
+ if (opt->foreground) {
+ target = pid;
+ vlog(opt, "sending signal %s(%d) to command '%s'",
+ signame, signo, opt->command_name);
+ } else {
+ target = -pid;
+ vlog(opt, "sending signal %s(%d) to command group '%s'",
+ signame, signo, opt->command_name);
+ }
+
+ if (kill(target, signo) != 0 && errno != ESRCH) {
+ fprintf(stderr, "%s: kill(%ld, %s): %s\n", program_name,
+ (long)target, signame, strerror(errno));
+ }
+
+ if (signo == SIGKILL || signo == SIGSTOP || signo == SIGCONT)
+ return;
+
+ if (!signal_name_for_number(SIGCONT, sigbuf, sizeof(sigbuf)))
+ snprintf(sigbuf, sizeof(sigbuf), "%d", SIGCONT);
+ vlog(opt, "sending signal %s(%d) to command '%s'",
+ sigbuf, SIGCONT, opt->command_name);
+ (void)kill(target, SIGCONT);
+}
+
+static void
+arm_second_timer(struct runtime_state *state, const struct options *opt)
+{
+ long double now;
+
+ if (!state->second_pending)
+ return;
+
+ now = monotonic_seconds();
+ state->second_deadline = now + opt->kill_after;
+ state->second_timer_active = true;
+ state->second_pending = false;
+ state->active_signal = SIGKILL;
+ state->first_timer_active = false;
+}
+
+static void
+reap_children(pid_t command_pid, struct child_state *child, bool *have_children,
+ const struct options *opt)
+{
+ pid_t waited;
+ int status;
+
+ *have_children = false;
+ for (;;) {
+ waited = waitpid(-1, &status, WNOHANG);
+ if (waited > 0) {
+ if (waited == command_pid) {
+ child->reaped = true;
+ child->status = status;
+ if (WIFEXITED(status)) {
+ vlog(opt, "child terminated: pid=%d exit=%d",
+ (int)waited, WEXITSTATUS(status));
+ } else if (WIFSIGNALED(status)) {
+ vlog(opt, "child terminated: pid=%d sig=%d",
+ (int)waited, WTERMSIG(status));
+ } else {
+ vlog(opt, "child changed state: pid=%d status=0x%x",
+ (int)waited, status);
+ }
+ } else {
+ vlog(opt, "collected descendant: pid=%d status=0x%x",
+ (int)waited, status);
+ }
+ continue;
+ }
+ if (waited == 0) {
+ *have_children = true;
+ return;
+ }
+ if (errno == EINTR)
+ continue;
+ if (errno == ECHILD)
+ return;
+ die_errno(EXIT_INVALID, "waitpid");
+ }
+}
+
+static void
+child_exec(const struct options *opt, const sigset_t *oldmask)
+{
+ struct sigaction sa;
+ int saved_errno;
+
+ if (!opt->foreground) {
+ if (setpgid(0, 0) != 0) {
+ dprintf(STDERR_FILENO, "%s: setpgid: %s\n",
+ program_name, strerror(errno));
+ _exit(EXIT_INVALID);
+ }
+ }
+
+ if (opt->timeout_signal != SIGKILL && opt->timeout_signal != SIGSTOP) {
+ memset(&sa, 0, sizeof(sa));
+ sa.sa_handler = SIG_DFL;
+ sigemptyset(&sa.sa_mask);
+ if (sigaction(opt->timeout_signal, &sa, NULL) != 0) {
+ dprintf(STDERR_FILENO, "%s: sigaction(%d): %s\n",
+ program_name, opt->timeout_signal, strerror(errno));
+ _exit(EXIT_INVALID);
+ }
+ }
+
+ if (sigprocmask(SIG_SETMASK, oldmask, NULL) != 0) {
+ dprintf(STDERR_FILENO, "%s: sigprocmask: %s\n",
+ program_name, strerror(errno));
+ _exit(EXIT_INVALID);
+ }
+
+ execvp(opt->command_argv[0], opt->command_argv);
+ saved_errno = errno;
+ dprintf(STDERR_FILENO, "%s: exec(%s): %s\n", program_name,
+ opt->command_argv[0], strerror(saved_errno));
+ _exit(saved_errno == ENOENT ? EXIT_CMD_NOENT : EXIT_CMD_ERROR);
+}
+
+static void
+kill_self(int signo)
+{
+ struct sigaction sa;
+ struct rlimit rl;
+ sigset_t mask;
+
+ memset(&rl, 0, sizeof(rl));
+ (void)setrlimit(RLIMIT_CORE, &rl);
+
+ memset(&sa, 0, sizeof(sa));
+ sa.sa_handler = SIG_DFL;
+ sigemptyset(&sa.sa_mask);
+ (void)sigaction(signo, &sa, NULL);
+
+ sigfillset(&mask);
+ sigdelset(&mask, signo);
+ (void)sigprocmask(SIG_SETMASK, &mask, NULL);
+
+ raise(signo);
+ _exit(128 + signo);
+}
+
+static bool
+process_group_exists(pid_t pgid)
+{
+ if (pgid <= 0)
+ return (false);
+ if (kill(-pgid, 0) == 0)
+ return (true);
+ if (errno == EPERM)
+ return (true);
+ if (errno == ESRCH)
+ return (false);
+ return (true);
+}
+
+int
+main(int argc, char **argv)
+{
+ const char optstr[] = "+fk:ps:v";
+ const struct option longopts[] = {
+ { "foreground", no_argument, NULL, 'f' },
+ { "kill-after", required_argument, NULL, 'k' },
+ { "preserve-status", no_argument, NULL, 'p' },
+ { "signal", required_argument, NULL, 's' },
+ { "verbose", no_argument, NULL, 'v' },
+ { NULL, 0, NULL, 0 },
+ };
+ struct options opt;
+ struct child_state child;
+ struct runtime_state state;
+ struct timespec timeout;
+ sigset_t allmask, oldmask;
+ pid_t child_pid;
+ int ch;
+
+ if (argv[0] != NULL && argv[0][0] != '\0')
+ program_name = basename_const(argv[0]);
+
+ memset(&opt, 0, sizeof(opt));
+ opt.timeout_signal = SIGTERM;
+
+ opterr = 0;
+ while ((ch = getopt_long(argc, argv, optstr, longopts, NULL)) != -1) {
+ switch (ch) {
+ case 'f':
+ opt.foreground = true;
+ break;
+ case 'k':
+ opt.kill_after = parse_duration_or_die(optarg);
+ opt.kill_after_set = true;
+ break;
+ case 'p':
+ opt.preserve = true;
+ break;
+ case 's':
+ if (!parse_signal_token(optarg, &opt.timeout_signal))
+ die(EXIT_INVALID, "invalid signal: %s", optarg);
+ break;
+ case 'v':
+ opt.verbose = true;
+ break;
+ default:
+ usage();
+ }
+ }
+
+ argc -= optind;
+ argv += optind;
+ if (argc < 2)
+ usage();
+
+ opt.duration = parse_duration_or_die(argv[0]);
+ opt.command_argv = &argv[1];
+ opt.command_name = argv[1];
+
+ if (!opt.foreground)
+ enable_subreaper_or_die();
+
+ sigfillset(&allmask);
+ sigdelset(&allmask, SIGKILL);
+ sigdelset(&allmask, SIGSTOP);
+ if (sigprocmask(SIG_SETMASK, &allmask, &oldmask) != 0)
+ die_errno(EXIT_INVALID, "sigprocmask");
+
+ child_pid = fork();
+ if (child_pid < 0)
+ die_errno(EXIT_INVALID, "fork");
+ if (child_pid == 0)
+ child_exec(&opt, &oldmask);
+
+ if (!opt.foreground) {
+ if (setpgid(child_pid, child_pid) != 0 &&
+ errno != EACCES && errno != ESRCH) {
+ die_errno(EXIT_INVALID, "setpgid");
+ }
+ }
+
+ memset(&child, 0, sizeof(child));
+ memset(&state, 0, sizeof(state));
+ state.active_signal = opt.timeout_signal;
+ state.second_pending = opt.kill_after_set;
+ if (opt.duration > 0.0L) {
+ state.first_timer_active = true;
+ state.first_deadline = monotonic_seconds() + opt.duration;
+ }
+
+ for (;;) {
+ enum deadline_kind deadline_kind;
+ bool have_children;
+ siginfo_t si;
+ int signo;
+
+ reap_children(child_pid, &child, &have_children, &opt);
+ if (opt.foreground) {
+ if (child.reaped)
+ break;
+ } else {
+ if (state.timed_out && have_children &&
+ !process_group_exists(child_pid)) {
+ die(EXIT_INVALID,
+ "descendant processes escaped command process group; "
+ "unsupported on Linux without --foreground");
+ }
+ if (child.reaped && !have_children)
+ break;
+ }
+
+ deadline_kind = next_deadline(&state, &timeout);
+ if (deadline_kind == DEADLINE_NONE)
+ signo = sigwaitinfo(&allmask, &si);
+ else
+ signo = sigtimedwait(&allmask, &si, &timeout);
+
+ if (signo >= 0) {
+ char sigbuf[32];
+
+ if (signo == SIGCHLD)
+ continue;
+
+ if (!signal_name_for_number(signo, sigbuf, sizeof(sigbuf)))
+ snprintf(sigbuf, sizeof(sigbuf), "%d", signo);
+
+ if (signo == SIGALRM) {
+ vlog(&opt, "received SIGALRM, treating as timeout");
+ state.timed_out = true;
+ state.first_timer_active = false;
+ send_signal_to_command(child_pid, state.active_signal, &opt);
+ arm_second_timer(&state, &opt);
+ continue;
+ }
+
+ if (signo == state.active_signal || is_terminating_signal(signo)) {
+ vlog(&opt, "received terminating signal %s(%d)",
+ sigbuf, signo);
+ send_signal_to_command(child_pid, signo, &opt);
+ arm_second_timer(&state, &opt);
+ } else {
+ vlog(&opt, "received signal %s(%d)", sigbuf, signo);
+ send_signal_to_command(child_pid, signo, &opt);
+ }
+ continue;
+ }
+
+ if (errno == EINTR)
+ continue;
+ if (errno != EAGAIN)
+ die_errno(EXIT_INVALID, "sigwait");
+
+ if (deadline_kind == DEADLINE_FIRST) {
+ state.timed_out = true;
+ state.first_timer_active = false;
+ vlog(&opt, "time limit reached");
+ send_signal_to_command(child_pid, state.active_signal, &opt);
+ arm_second_timer(&state, &opt);
+ } else if (deadline_kind == DEADLINE_SECOND) {
+ state.second_timer_active = false;
+ vlog(&opt, "kill-after limit reached");
+ send_signal_to_command(child_pid, SIGKILL, &opt);
+ }
+ }
+
+ (void)sigprocmask(SIG_SETMASK, &oldmask, NULL);
+
+ if (!child.reaped)
+ die(EXIT_INVALID, "failed to retrieve command status");
+
+ if (state.timed_out && !opt.preserve)
+ return (EXIT_TIMEOUT);
+
+ if (WIFEXITED(child.status))
+ return (WEXITSTATUS(child.status));
+ if (WIFSIGNALED(child.status))
+ kill_self(WTERMSIG(child.status));
+
+ return (EXIT_INVALID);
+}