From 1586df01f735ba1a17bc19436910a2f4b37fe2f6 Mon Sep 17 00:00:00 2001 From: Lucas Holt Date: Tue, 8 Sep 2026 17:24:23 -0400 Subject: [PATCH] time: add -f format option compatible with GNU time Add a -f option to time(1) that prints resource usage according to a user supplied format string. The conversion characters follow GNU time (%e, %E, %U, %S, %P, %M, %x, %C and friends) so that scripts written against it on Linux work unchanged. The averaged memory conversions reuse the tick arithmetic already used by -l. -f takes precedence over -h and -p; -l output still follows the formatted line. Document the option and every conversion in time.1, and add a Kyua test suite covering the escapes, exit status and signal reporting, flag precedence and -o/-a handling. AI-Assisted-by: Claude Fable 5.1 Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01H3gnjjAZarNMCJKqJz7s2m Signed-off-by: Lucas Holt --- etc/mtree/BSD.tests.dist | 2 + usr.bin/time/Makefile | 5 + usr.bin/time/tests/Makefile | 4 + usr.bin/time/tests/time_test.sh | 171 ++++++++++++++++++++++++ usr.bin/time/time.1 | 115 +++++++++++++++- usr.bin/time/time.c | 223 +++++++++++++++++++++++++++++--- 6 files changed, 502 insertions(+), 18 deletions(-) create mode 100644 usr.bin/time/tests/Makefile create mode 100644 usr.bin/time/tests/time_test.sh diff --git a/etc/mtree/BSD.tests.dist b/etc/mtree/BSD.tests.dist index f62164998a2..4c0472ceec4 100644 --- a/etc/mtree/BSD.tests.dist +++ b/etc/mtree/BSD.tests.dist @@ -1151,6 +1151,8 @@ .. tar .. + time + .. touch .. tr diff --git a/usr.bin/time/Makefile b/usr.bin/time/Makefile index ae649c7c3e0..3390510db63 100644 --- a/usr.bin/time/Makefile +++ b/usr.bin/time/Makefile @@ -1,5 +1,10 @@ # @(#)Makefile 8.1 (Berkeley) 6/6/93 +.include + PROG= time +HAS_TESTS= +SUBDIR.${MK_TESTS}+= tests + .include diff --git a/usr.bin/time/tests/Makefile b/usr.bin/time/tests/Makefile new file mode 100644 index 00000000000..6025289f414 --- /dev/null +++ b/usr.bin/time/tests/Makefile @@ -0,0 +1,4 @@ +PACKAGE= tests +ATF_TESTS_SH= time_test + +.include diff --git a/usr.bin/time/tests/time_test.sh b/usr.bin/time/tests/time_test.sh new file mode 100644 index 00000000000..54557145e8f --- /dev/null +++ b/usr.bin/time/tests/time_test.sh @@ -0,0 +1,171 @@ +# +# Copyright (c) 2026 Lucas Holt +# +# SPDX-License-Identifier: BSD-2-Clause +# + +TIME=/usr/bin/time + +atf_test_case format_literal +format_literal_head() { + atf_set descr "-f copies literal text and appends a newline" +} +format_literal_body() { + atf_check -s exit:0 -e inline:"hello world\n" \ + ${TIME} -f "hello world" true +} + +atf_test_case format_percent +format_percent_head() { + atf_set descr "-f prints %% as a single percent sign" +} +format_percent_body() { + atf_check -s exit:0 -e inline:"100%\n" \ + ${TIME} -f "100%%" true +} + +atf_test_case format_backslash +format_backslash_head() { + atf_set descr "-f expands \\t, \\n and \\\\" +} +format_backslash_body() { + printf 'a\tb\nc\\d\n' >expected + atf_check -s exit:0 -e file:expected \ + ${TIME} -f 'a\tb\nc\\d' true +} + +atf_test_case format_unknown +format_unknown_head() { + atf_set descr "-f prints unknown conversions as ?c" +} +format_unknown_body() { + atf_check -s exit:0 -e inline:"?q\n" \ + ${TIME} -f "%q" true +} + +atf_test_case format_trailing +format_trailing_head() { + atf_set descr "-f keeps a trailing % or backslash literal" +} +format_trailing_body() { + atf_check -s exit:0 -e inline:"x%\n" ${TIME} -f "x%" true + atf_check -s exit:0 -e inline:"x\\\\\n" ${TIME} -f 'x\' true +} + +atf_test_case format_command +format_command_head() { + atf_set descr "%C prints the command and its arguments" +} +format_command_body() { + atf_check -s exit:0 -e inline:"sh -c exit 0\n" \ + ${TIME} -f "%C" sh -c "exit 0" +} + +atf_test_case format_exit_status +format_exit_status_head() { + atf_set descr "%x prints the exit status of the command" +} +format_exit_status_body() { + atf_check -s exit:0 -e inline:"0\n" ${TIME} -f "%x" true + atf_check -s exit:3 -e inline:"3\n" ${TIME} -f "%x" sh -c "exit 3" +} + +atf_test_case format_signal +format_signal_head() { + atf_set descr "%x prints the signal number when the command is killed" +} +format_signal_body() { + atf_check -s signal:15 -e match:"^15$" \ + ${TIME} -f "%x" sh -c 'kill -TERM $$' +} + +atf_test_case format_elapsed +format_elapsed_head() { + atf_set descr "%e and %E report the elapsed time" +} +format_elapsed_body() { + atf_check -s exit:0 -e match:"^[1-9][.,][0-9][0-9]$" \ + ${TIME} -f "%e" sleep 1 + atf_check -s exit:0 -e match:"^0:0[1-9][.,][0-9][0-9]$" \ + ${TIME} -f "%E" sleep 1 + atf_check -s exit:0 -e match:"^0[.,][0-9][0-9]$" \ + ${TIME} -f "%e" true +} + +atf_test_case format_numeric +format_numeric_head() { + atf_set descr "numeric conversions produce numbers" +} +format_numeric_body() { + atf_check -s exit:0 \ + -e match:"^U=[0-9]+[.,][0-9]{2} S=[0-9]+[.,][0-9]{2} P=[0-9?]+%$" \ + ${TIME} -f "U=%U S=%S P=%P" true + atf_check -s exit:0 \ + -e match:"^M=[0-9]+ t=[0-9]+ K=[0-9]+ D=[0-9]+ p=[0-9]+ X=[0-9]+$" \ + ${TIME} -f "M=%M t=%t K=%K D=%D p=%p X=%X" true + atf_check -s exit:0 \ + -e match:"^F=[0-9]+ R=[0-9]+ W=[0-9]+ c=[0-9]+ w=[0-9]+$" \ + ${TIME} -f "F=%F R=%R W=%W c=%c w=%w" true + atf_check -s exit:0 \ + -e match:"^I=[0-9]+ O=[0-9]+ r=[0-9]+ s=[0-9]+ k=[0-9]+$" \ + ${TIME} -f "I=%I O=%O r=%r s=%s k=%k" true +} + +atf_test_case format_pagesize +format_pagesize_head() { + atf_set descr "%Z matches the system page size" +} +format_pagesize_body() { + pagesize=$(sysctl -n hw.pagesize) + atf_check -s exit:0 -e inline:"${pagesize}\n" ${TIME} -f "%Z" true +} + +atf_test_case format_overrides +format_overrides_head() { + atf_set descr "-f takes precedence over -p and -h, -l still appends" +} +format_overrides_body() { + atf_check -s exit:0 -e inline:"fmt\n" ${TIME} -p -f "fmt" true + atf_check -s exit:0 -e inline:"fmt\n" ${TIME} -h -f "fmt" true + atf_check -s exit:0 -e match:"^fmt$" \ + -e match:"maximum resident set size" \ + ${TIME} -l -f "fmt" true +} + +atf_test_case format_output_file +format_output_file_head() { + atf_set descr "-f output honours -o and -a" +} +format_output_file_body() { + atf_check -s exit:0 ${TIME} -o out.txt -f "first %x" true + atf_check -o inline:"first 0\n" cat out.txt + atf_check -s exit:0 ${TIME} -a -o out.txt -f "second %x" true + atf_check -o inline:"first 0\nsecond 0\n" cat out.txt + atf_check -s exit:0 ${TIME} -o out.txt -f "third %x" true + atf_check -o inline:"third 0\n" cat out.txt +} + +atf_test_case format_missing_arg +format_missing_arg_head() { + atf_set descr "-f without an argument is a usage error" +} +format_missing_arg_body() { + atf_check -s exit:1 -e match:"usage:" ${TIME} -f +} + +atf_init_test_cases() { + atf_add_test_case format_literal + atf_add_test_case format_percent + atf_add_test_case format_backslash + atf_add_test_case format_unknown + atf_add_test_case format_trailing + atf_add_test_case format_command + atf_add_test_case format_exit_status + atf_add_test_case format_signal + atf_add_test_case format_elapsed + atf_add_test_case format_numeric + atf_add_test_case format_pagesize + atf_add_test_case format_overrides + atf_add_test_case format_output_file + atf_add_test_case format_missing_arg +} diff --git a/usr.bin/time/time.1 b/usr.bin/time/time.1 index 7f3448bba39..cc8ebb0737f 100644 --- a/usr.bin/time/time.1 +++ b/usr.bin/time/time.1 @@ -27,7 +27,7 @@ .\" .\" @(#)time.1 8.1 (Berkeley) 6/6/93 .\" -.Dd July 7, 2020 +.Dd September 8, 2026 .Dt TIME 1 .Os .Sh NAME @@ -36,7 +36,7 @@ .Sh SYNOPSIS .Nm .Op Fl al -.Op Fl h | Fl p +.Op Fl f Ar format | Fl h | Fl p .Op Fl o Ar file .Ar utility Op Ar argument ... .Sh DESCRIPTION @@ -65,6 +65,107 @@ If the flag is used, append to the specified file rather than overwriting it. Otherwise, this option has no effect. +.It Fl f Ar format +Print the resource usage using +.Ar format +instead of the default output. +The format string is copied to the output verbatim, with the following +exceptions: +a newline is appended, +the sequences +.Ql \et , +.Ql \en +and +.Ql \e\e +produce a tab, a newline and a backslash respectively, +and each +.Ql % +introduces a conversion described below. +An unknown conversion is printed as a +.Ql \&? +followed by the offending character. +The conversions are compatible with those of GNU +.Nm , +so scripts written for it can be used unchanged. +.Pp +Time: +.Bl -tag -width indent -compact +.It Cm \&%E +Elapsed real time, as +.Ar minutes:seconds.hundredths , +or +.Ar hours:minutes:seconds +if the command ran for an hour or more. +.It Cm \&%e +Elapsed real time in seconds. +.It Cm \&%S +Total CPU seconds spent in kernel mode. +.It Cm \&%U +Total CPU seconds spent in user mode. +.It Cm \&%P +Percentage of the CPU this job got, computed as +.Cm ( \&%U No + Cm \&%S ) No / Cm \&%e . +.El +.Pp +Memory (all sizes are in kilobytes): +.Bl -tag -width indent -compact +.It Cm \&%M +Maximum resident set size. +.It Cm \&%t +Average resident set size (unshared data). +.It Cm \&%K +Average total memory use (data, stack and text). +.It Cm \&%D +Average size of the unshared data and stack areas. +.It Cm \&%p +Average size of the unshared stack area. +.It Cm \&%X +Average size of the shared text area. +.It Cm \&%Z +The system page size, in bytes. +.It Cm \&%F +Number of major page faults. +.It Cm \&%R +Number of minor page faults (page reclaims). +.It Cm \&%W +Number of times the process was swapped out. +.It Cm \&%c +Number of involuntary context switches. +.It Cm \&%w +Number of voluntary context switches. +.El +.Pp +I/O and miscellaneous: +.Bl -tag -width indent -compact +.It Cm \&%I +Number of block input operations. +.It Cm \&%O +Number of block output operations. +.It Cm \&%r +Number of socket messages received. +.It Cm \&%s +Number of socket messages sent. +.It Cm \&%k +Number of signals delivered to the process. +.It Cm \&%C +The name and arguments of the command being timed. +.It Cm \&%x +The exit status of the command, or the number of the signal that +terminated it. +.It Cm \&%% +A literal +.Ql % . +.El +.Pp +The +.Fl f +option takes precedence over +.Fl h +and +.Fl p . +The +.Fl l +output, if requested, follows the formatted line. .It Fl h Print times in a human friendly format. Times are printed in minutes, hours, @@ -102,7 +203,7 @@ receives a (see the status argument for .Xr stty 1 ) signal, the current time the given command is running will be written to the -standard output. +standard output in the default format. .Sh ENVIRONMENT The .Ev PATH @@ -184,6 +285,14 @@ sys 0.00 3 voluntary context switches 0 involuntary context switches .Ed +.Pp +Print only the elapsed time and the peak memory use of a +.Xr sort 1 +run, using a custom format: +.Bd -literal -offset indent +$ /usr/bin/time -f "%e seconds, %M KB max RSS, exit %x" sort -o /dev/null words +0.12 seconds, 6740 KB max RSS, exit 0 +.Ed .Sh SEE ALSO .Xr builtin 1 , .Xr csh 1 , diff --git a/usr.bin/time/time.c b/usr.bin/time/time.c index 8e45f128331..16c2922c387 100644 --- a/usr.bin/time/time.c +++ b/usr.bin/time/time.c @@ -60,7 +60,10 @@ static char sccsid[] = "@(#)time.c 8.1 (Berkeley) 6/6/93"; #include static int getstathz(void); +static u_long getticks(struct rusage *); static void humantime(FILE *, long, long); +static void showfmt(FILE *, const char *, struct timespec *, + struct timespec *, struct rusage *, int, char **); static void showtime(FILE *, struct timespec *, struct timespec *, struct rusage *); static void siginfo(int); @@ -81,17 +84,21 @@ main(int argc, char **argv) struct rusage ru; struct timespec after; char *ofn = NULL; + const char *fmt = NULL; FILE *out = stderr; (void) setlocale(LC_NUMERIC, ""); decimal_point = localeconv()->decimal_point[0]; aflag = hflag = lflag = pflag = 0; - while ((ch = getopt(argc, argv, "ahlo:p")) != -1) + while ((ch = getopt(argc, argv, "af:hlo:p")) != -1) switch((char)ch) { case 'a': aflag = 1; break; + case 'f': + fmt = optarg; + break; case 'h': hflag = 1; break; @@ -150,20 +157,12 @@ main(int argc, char **argv) if ( ! WIFEXITED(status)) warnx("command terminated abnormally"); exitonsig = WIFSIGNALED(status) ? WTERMSIG(status) : 0; - showtime(out, &before_ts, &after, &ru); + if (fmt != NULL) + showfmt(out, fmt, &before_ts, &after, &ru, status, argv); + else + showtime(out, &before_ts, &after, &ru); if (lflag) { - int hz = getstathz(); - u_long ticks; - - ticks = hz * (ru.ru_utime.tv_sec + ru.ru_stime.tv_sec) + - hz * (ru.ru_utime.tv_usec + ru.ru_stime.tv_usec) / 1000000; - - /* - * If our round-off on the tick calculation still puts us at 0, - * then always assume at least one tick. - */ - if (ticks == 0) - ticks = 1; + u_long ticks = getticks(&ru); fprintf(out, "%10ld %s\n", ru.ru_maxrss, "maximum resident set size"); @@ -216,7 +215,8 @@ static void usage(void) { fprintf(stderr, - "usage: time [-al] [-h | -p] [-o file] utility [argument ...]\n"); + "usage: time [-al] [-f format | -h | -p] [-o file] utility " + "[argument ...]\n"); exit(1); } @@ -238,6 +238,29 @@ getstathz(void) return clockrate.stathz; } +/* + * Return the number of statistics clock ticks the process consumed. + * The ru_ixrss, ru_idrss and ru_isrss fields are integrals over ticks, + * so dividing them by this yields an average size in kilobytes. + */ +static u_long +getticks(struct rusage *ru) +{ + int hz = getstathz(); + u_long ticks; + + ticks = hz * (ru->ru_utime.tv_sec + ru->ru_stime.tv_sec) + + hz * (ru->ru_utime.tv_usec + ru->ru_stime.tv_usec) / 1000000; + + /* + * If our round-off on the tick calculation still puts us at 0, + * then always assume at least one tick. + */ + if (ticks == 0) + ticks = 1; + return (ticks); +} + static void humantime(FILE *out, long sec, long centisec) { @@ -303,6 +326,176 @@ showtime(FILE *out, struct timespec *before, struct timespec *after, } } +/* + * Print resource usage according to a user supplied format string. + * The escapes follow the ones used by GNU time so that existing scripts + * written against it keep working. + */ +static void +showfmt(FILE *out, const char *fmt, struct timespec *before, + struct timespec *after, struct rusage *ru, int status, char **argv) +{ + struct timespec el; + intmax_t cpu_us, wall_us; + u_long ticks; + long hrs, mins, secs; + const char *p; + char **ap; + + el.tv_sec = after->tv_sec - before->tv_sec; + el.tv_nsec = after->tv_nsec - before->tv_nsec; + if (el.tv_nsec < 0) { + el.tv_sec--; + el.tv_nsec += 1000000000; + } + ticks = getticks(ru); + + for (p = fmt; *p != '\0'; p++) { + if (*p != '%' && *p != '\\') { + putc(*p, out); + continue; + } + /* A lone '%' or '\\' at the end of the string is literal. */ + if (p[1] == '\0') { + putc(*p, out); + break; + } + if (*p == '\\') { + switch (*++p) { + case 'n': + putc('\n', out); + break; + case 't': + putc('\t', out); + break; + case '\\': + putc('\\', out); + break; + default: + putc('\\', out); + putc(*p, out); + break; + } + continue; + } + switch (*++p) { + case '%': + putc('%', out); + break; + case 'C': /* command name and arguments */ + for (ap = argv; *ap != NULL; ap++) + fprintf(out, "%s%s", ap == argv ? "" : " ", + *ap); + break; + case 'D': /* average unshared data + stack, KB */ + fprintf(out, "%ld", + ru->ru_idrss / ticks + ru->ru_isrss / ticks); + break; + case 'E': /* elapsed, [h:]m:s */ + secs = el.tv_sec; + hrs = secs / 3600; + secs %= 3600; + mins = secs / 60; + secs %= 60; + if (hrs != 0) + fprintf(out, "%ld:%02ld:%02ld", hrs, mins, + secs); + else + fprintf(out, "%ld:%02ld%c%02ld", mins, secs, + decimal_point, el.tv_nsec / 10000000); + break; + case 'F': /* major page faults */ + fprintf(out, "%ld", ru->ru_majflt); + break; + case 'I': /* block input operations */ + fprintf(out, "%ld", ru->ru_inblock); + break; + case 'K': /* average total memory, KB */ + fprintf(out, "%ld", ru->ru_idrss / ticks + + ru->ru_isrss / ticks + ru->ru_ixrss / ticks); + break; + case 'M': /* maximum resident set size, KB */ + fprintf(out, "%ld", ru->ru_maxrss); + break; + case 'O': /* block output operations */ + fprintf(out, "%ld", ru->ru_oublock); + break; + case 'P': /* CPU percentage */ + cpu_us = (intmax_t)ru->ru_utime.tv_sec * 1000000 + + ru->ru_utime.tv_usec + + (intmax_t)ru->ru_stime.tv_sec * 1000000 + + ru->ru_stime.tv_usec; + wall_us = (intmax_t)el.tv_sec * 1000000 + + el.tv_nsec / 1000; + if (wall_us <= 0) + fprintf(out, "?%%"); + else + fprintf(out, "%jd%%", cpu_us * 100 / wall_us); + break; + case 'R': /* minor page faults */ + fprintf(out, "%ld", ru->ru_minflt); + break; + case 'S': /* system CPU seconds */ + fprintf(out, "%jd%c%02ld", + (intmax_t)ru->ru_stime.tv_sec, decimal_point, + ru->ru_stime.tv_usec / 10000); + break; + case 'U': /* user CPU seconds */ + fprintf(out, "%jd%c%02ld", + (intmax_t)ru->ru_utime.tv_sec, decimal_point, + ru->ru_utime.tv_usec / 10000); + break; + case 'W': /* swaps */ + fprintf(out, "%ld", ru->ru_nswap); + break; + case 'X': /* average shared text, KB */ + fprintf(out, "%ld", ru->ru_ixrss / ticks); + break; + case 'Z': /* page size */ + fprintf(out, "%d", getpagesize()); + break; + case 'c': /* involuntary context switches */ + fprintf(out, "%ld", ru->ru_nivcsw); + break; + case 'e': /* elapsed seconds */ + fprintf(out, "%jd%c%02ld", (intmax_t)el.tv_sec, + decimal_point, el.tv_nsec / 10000000); + break; + case 'k': /* signals received */ + fprintf(out, "%ld", ru->ru_nsignals); + break; + case 'p': /* average unshared stack, KB */ + fprintf(out, "%ld", ru->ru_isrss / ticks); + break; + case 'r': /* socket messages received */ + fprintf(out, "%ld", ru->ru_msgrcv); + break; + case 's': /* socket messages sent */ + fprintf(out, "%ld", ru->ru_msgsnd); + break; + case 't': /* average resident set size, KB */ + fprintf(out, "%ld", ru->ru_idrss / ticks); + break; + case 'w': /* voluntary context switches */ + fprintf(out, "%ld", ru->ru_nvcsw); + break; + case 'x': /* exit status */ + if (WIFSIGNALED(status)) + fprintf(out, "%d", WTERMSIG(status)); + else if (WIFSTOPPED(status)) + fprintf(out, "%d", WSTOPSIG(status)); + else + fprintf(out, "%d", WEXITSTATUS(status)); + break; + default: /* unknown escape */ + putc('?', out); + putc(*p, out); + break; + } + } + putc('\n', out); +} + static void siginfo(int sig __unused) {