From: Job Snijders Subject: watch(1) - periodically execute a command and display its output To: tech@openbsd.org Date: Mon, 19 May 2025 20:00:37 +0000 Dear all, I often find myself reaching for a type of utility not in base, "watch". A makeshift solution is "$ while true; do THING; sleep 1; clear; done", but I often end up spending too much time tweaking such impromptu oneliners, essentially cooking up hairy shell scripts with no newlines. For years I used 'gnuwatch' from ports, it works okayish, but the name spells out an issue. Then, this weekend I discovered yazuoka@'s excellent ports/sysutils/iwatch and decided to prepare it for OpenBSD. https://github.com/iij/iwatch v1.0.4 was my starting point. compared to iwatch: - removed colorizing capability / style via environment variable - changed a number of keyboard commands (added arrows/pgup/pgdown) - rewrote the man page - made interactive mode more like top(1)/systat(1) - added 'pause on error' feature - added pledge/unveil watch compared to gnuwatch: - also uses '-n' to set the desired interval (this aspect probably makes the tools interchangable for 99% of users) - gnuwatch lacks some interactive toggles - gnuwatch can highlight changed characters, but not words and lines; the latter two often provide great clarity in a visual fashion. - gnuwatch lacks the ability to scroll through the command's output This is still a bit rough around the edges, but perhaps we can work on it together in tree? Your feedback is most welcome! お疲れ様でした Job Index: usr.bin/watch/watch.1 =================================================================== RCS file: usr.bin/watch/watch.1 diff -N usr.bin/watch/watch.1 --- /dev/null 1 Jan 1970 00:00:00 -0000 +++ usr.bin/watch/watch.1 19 May 2025 19:17:31 -0000 @@ -0,0 +1,138 @@ +.\" $OpenBSD$ +.\" +.\" Copyright (c) 2025 Job Snijders +.\" Copyright (c) 2000, 2001, 2014, 2016 Internet Initiative Japan Inc. +.\" +.\" Permission to use, copy, modify, and distribute this software for any +.\" purpose with or without fee is hereby granted, provided that the above +.\" copyright notice and this permission notice appear in all copies. +.\" +.\" THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +.\" WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +.\" MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR +.\" ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +.\" WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN +.\" ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF +.\" OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. +.\" +.Dd $Mdocdate$ +.Dt WATCH 1 +.Os +.Sh NAME +.Nm watch +.Nd periodically execute a command and display its output +.Sh SYNOPSIS +.Nm +.Op Fl celwx +.Op Fl n Ar seconds +.Ar command Op Ar argument ... +.Sh DESCRIPTION +The +.Nm +utility periodically executes and displays the output of +.Ar command +with +.Ar argument . +Differences between successive runs can be highlighted. +.Pp +The options are as follows: +.Bl -tag -width Ds +.It Fl c +Highlight changed characters. +.It Fl e +Pause updating if +.Ar command +exits with a non-zero exit code. +.It Fl l +Highlight changed lines. +.It Fl n Ar seconds +Set the interval between updates to +.Ar seconds . +The value may be zero or fractional. +The default is 1 second. +.It Fl w +Highlight changed words. +.It Fl x +Pass +.Ar command +and +.Ar arguments +to +.Xr execl 3 +instead of +.Ic sh -c , +for different quoting and shell escaping behaviour. +.El +.Sh INTERACTIVE COMMANDS +Certain characters cause immediate action by +.Nm . +These are: +.Bl -tag -width Ds +.It Aq Ic Space +Run +.Ar command +again. +.It Aq Ic Page Down +Scroll down a screenful. +.It Aq Ic Page Up +Scroll up a screenful. +.It Ic \&[ | Aq Ic left arrow +Scroll left by one column. +.It Ic \&] | Aq Ic right arrow +Scroll right by one column. +.It Ic c +Highlight changed characters. +.It Ic G +Scroll to bottom. +.It Ic g +Scroll to top. +.It Ic H +Scroll left half a screen. +.It Ic h | Ic \&? +Display a summary of the commands (help screen). +.It Ic J +Scroll down half a screen. +.It Ic j | Aq Ic down arrow +Scroll down one line. +.It Ic K +Scroll up half a screen. +.It Ic k | Aq Ic up arrow +Scroll up one line. +.It Ic L +Scroll right half a screen. +.It Ic l +Highlight changed lines. +.It Ic n +Change the update interval. +.It Ic p +Pause or resume command executions. +.Aq Ic Space +can be used while paused. +.It Ic t +Toggle the display of highlights. +.It Ic w +Highlight changed words. +.It Ic q +Quit +.Nm . +.El +.Sh SEE ALSO +.Xr sh 1 , +.Xr execl 3 +.Sh HISTORY +A +.Nm +utility written by Tony Rems first appeared in the procps collection in 1991. +This +.Nm +utility was originally named iwatch, a from-scratch rewrite for BSD by IIJ. +.Pp +.Nm +first appeared in +.Ox 7.8 . +.Sh AUTHORS +This +.Nm +utility was written by Takuya Sato, Kazumasa Utashiro, and YASUOKA Masahiko +from Internet Initiative Japan Inc., with contributions from +.An Job Snijders Aq Mt job@openbsd.org . Index: usr.bin/watch/watch.c =================================================================== RCS file: usr.bin/watch/watch.c diff -N usr.bin/watch/watch.c --- /dev/null 1 Jan 1970 00:00:00 -0000 +++ usr.bin/watch/watch.c 19 May 2025 19:17:31 -0000 @@ -0,0 +1,735 @@ +/* $OpenBSD$ */ +/* + * Copyright (c) 2025 Job Snijders + * Copyright (c) 2000, 2001 Internet Initiative Japan Inc. + * 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. Redistribution with functional modification must include + * prominent notice stating how and when and by whom it is + * modified. + * + * THIS SOFTWARE IS PROVIDED BY ``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. + */ + +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#define DEFAULT_INTERVAL 1 + +/* XXX: are the below 3 defines really enough? */ +#define MAXLINE 300 +#define MAXCOLUMN 180 +#define MAX_COMMAND_LENGTH 128 + +/* XXX: is this really needed? */ +#define NUM_FRAQ_DIGITS_USEC 6 /* number of fractal digits for usec */ + +#define addwch(_x) addnwstr(&(_x), 1); +#define WCWIDTH(_x) ((wcwidth((_x)) > 0)? wcwidth((_x)) : 1) +#define MAX(x, y) ((x) > (y) ? (x) : (y)) +#define MIN(x, y) ((x) < (y) ? (x) : (y)) +#define ctrl(c) ((c) & 037) + +typedef wchar_t BUFFER[MAXLINE][MAXCOLUMN + 1]; + +typedef enum { + HIGHLIGHT_NONE, + HIGHLIGHT_CHAR, + HIGHLIGHT_WORD, + HIGHLIGHT_LINE +} highlight_mode_t; + +typedef enum { + RSLT_UPDATE, + RSLT_REDRAW, + RSLT_NOTOUCH, + RSLT_ERROR +} kbd_result_t; + +int start_line = 0; +int start_column = 0; + +struct timeval opt_interval = { DEFAULT_INTERVAL, 0 }; +highlight_mode_t highlight_mode = HIGHLIGHT_NONE; +highlight_mode_t last_highlight_mode = HIGHLIGHT_CHAR; + +int pause_on_error = 0; +int paused = 0; /* pause status */ +int last_exitcode = 0; +time_t lastupdate; +int xflag = 0; + +static char *cmdstr; +static char **cmdv; + +void command_loop(void); +int display(BUFFER *, BUFFER *, highlight_mode_t); +void read_result(BUFFER *); +kbd_result_t kbd_command(int); +void show_help(void); +void untabify(wchar_t *, int); +void on_signal(int); +void quit(void); +static void __dead usage(void); + +int +main(int argc, char *argv[]) +{ + int i, ch, cmdsiz = 0; + char *endp, *s; + double intvl; + + if (pledge("exec proc rpath stdio tty unveil", NULL) == -1) + err(1, "pledge"); + + while ((ch = getopt(argc, argv, "celn:wx")) != -1) + switch (ch) { + case 'c': + highlight_mode = HIGHLIGHT_CHAR; + break; + case 'e': + pause_on_error = 1; + break; + case 'l': + highlight_mode = HIGHLIGHT_LINE; + break; + case 'n': + intvl = strtod(optarg, &endp); + + if (intvl >= 0 && intvl <= 1000000 && *endp == '\0') { + opt_interval.tv_sec = (int)intvl; + opt_interval.tv_usec = + (u_long)(intvl * 1000000UL) % 1000000UL; + break; + } else { + errx(1, "-n: bad value: %s", optarg); + } + break; + case 'w': + highlight_mode = HIGHLIGHT_WORD; + break; + case 'x': + xflag = 1; + break; + default: + usage(); + } + + argc -= optind; + argv += optind; + + /* + * Build command string to give to popen + */ + if (argc <= 0) + usage(); + + if ((cmdv = calloc(argc + 1, sizeof(char *))) == NULL) + err(1, NULL); + + cmdstr = ""; + for (i = 0; i < argc; i++) { + cmdv[i] = argv[i]; + while (strlen(cmdstr) + strlen(argv[i]) + 3 > cmdsiz) { + if (cmdsiz == 0) { + cmdsiz = 128; + if ((s = calloc(cmdsiz, 1)) == NULL) + err(1, NULL); + } else { + cmdsiz *= 2; + s = realloc(cmdstr, cmdsiz); + } + if (s == NULL) + err(EX_OSERR, "malloc"); + cmdstr = s; + } + if (i != 0) + strlcat(cmdstr, " ", cmdsiz); + strlcat(cmdstr, argv[i], cmdsiz); + } + cmdv[i++] = NULL; + + /* + * Initialize signal + */ + (void) signal(SIGINT, on_signal); + (void) signal(SIGTERM, on_signal); + (void) signal(SIGHUP, on_signal); + + /* + * Initialize curses environment + */ + initscr(); + noecho(); + crmode(); + keypad(stdscr, TRUE); + + /* + * Enter main processing loop and never come back here + */ + command_loop(); + + /* NOTREACHED */ + abort(); +} + +void +command_loop(void) +{ + int i, nfds; + BUFFER buf0, buf1; + fd_set readfds; + struct timeval to; + + for (i = 0; ; i++) { + BUFFER *cur, *prev; + + if (i == 0) { + cur = prev = &buf0; + } else if (i % 2 == 0) { + cur = &buf0; + prev = &buf1; + } else { + cur = &buf1; + prev = &buf0; + } + + read_result(cur); + + redraw: + display(cur, prev, highlight_mode); + + input: + to = opt_interval; + FD_ZERO(&readfds); + FD_SET(fileno(stdin), &readfds); + + nfds = select(1, &readfds, NULL, NULL, (paused) ? NULL : &to); + if (nfds < 0) { + switch (errno) { + case EINTR: + /* + * ncurses has changed the window size with + * SIGWINCH. Call doupdate() to use the + * updated window size. + */ + doupdate(); + goto redraw; + default: + perror("select"); + } + } else if (nfds > 0) { + int ch = getch(); + kbd_result_t result = kbd_command(ch); + + switch (result) { + case RSLT_UPDATE: /* update buffer */ + break; + case RSLT_REDRAW: /* scroll with current buffer */ + goto redraw; + case RSLT_NOTOUCH: /* silently loop again */ + goto input; + case RSLT_ERROR: /* error */ + fprintf(stderr, "\007"); + goto input; + } + } + } +} + +int +display(BUFFER *cur, BUFFER *prev, highlight_mode_t highlight) +{ + int i, val, screen_x, screen_y, cw, line, rl; + static char buf[30]; + + erase(); + + move(0, 0); + + if (paused) + printw("--PAUSED-- "); + + if (opt_interval.tv_usec == 0) { + printw("Every %ds: ", (int)opt_interval.tv_sec); + } else { + for (i = NUM_FRAQ_DIGITS_USEC, val = opt_interval.tv_usec; + val % 10 == 0; val /= 10) { + i--; + } + printw("Every %d.%0*ds: ", (int)opt_interval.tv_sec, i, val); + } + + if ((int)strlen(cmdstr) > COLS - 47) + printw("%-.*s...", COLS - 49, cmdstr); + else + printw("%s", cmdstr); + + if (pause_on_error) + printw(" (%d)", last_exitcode); + + if (buf[0] == '\0') + gethostname(buf, sizeof(buf)); + + move(0, COLS - 8 - strlen(buf) - 1); + printw("%s %-8.8s", buf, &(ctime(&lastupdate)[11])); + + move(1, 1); + + if (!prev || (cur == prev)) + highlight = HIGHLIGHT_NONE; + + for (line = start_line, screen_y = 2; + screen_y < LINES && line < MAXLINE && (*cur)[line][0]; + line++, screen_y++) { + wchar_t *cur_line, *prev_line, *p, *pp; + + rl = 0; /* reversing line */ + cur_line = (*cur)[line]; + prev_line = (*prev)[line]; + + for (p = cur_line, cw = 0; cw < start_column; p++) + cw += WCWIDTH(*p); + screen_x = cw - start_column; + for (pp = prev_line, cw = 0; cw < start_column; pp++) + cw += WCWIDTH(*pp); + + switch (highlight) { + case HIGHLIGHT_LINE: + if (wcscmp(cur_line, prev_line)) { + standout(); + rl = 1; + for (i = 0; i < screen_x; i++) { + move(screen_y, i); + addch(' '); + } + } + /* FALLTHROUGH */ + + case HIGHLIGHT_NONE: + move(screen_y, screen_x); + while (screen_x < COLS) { + if (*p && *p != L'\n') { + cw = wcwidth(*p); + if (screen_x + cw >= COLS) + break; + addwch(*p++); + pp++; + screen_x += cw; + } else if (rl) { + addch(' '); + screen_x++; + } else + break; + } + standend(); + break; + + case HIGHLIGHT_WORD: + case HIGHLIGHT_CHAR: + move(screen_y, screen_x); + while (*p && screen_x < COLS) { + cw = wcwidth(*p); + if (screen_x + cw >= COLS) + break; + if (*p == *pp) { + addwch(*p++); + pp++; + screen_x += cw; + continue; + } + /* + * If the word highlight option is specified and + * the current character is not a space, track + * back to the beginning of the word. + */ + if (highlight == HIGHLIGHT_WORD + && !iswspace(*p)) { + while (cur_line + start_column < p + && !iswspace(*(p - 1))) { + p--; + pp--; + screen_x -= wcwidth(*p); + } + move(screen_y, screen_x); + } + standout(); + + /* Print character itself. */ + cw = wcwidth(*p); + addwch(*p++); + pp++; + screen_x += cw; + + /* + * If the word highlight option is specified, + * and the current character is not a space, + * print the whole word which includes current + * character. + */ + if (highlight == HIGHLIGHT_WORD) { + while (*p && !iswspace(*p) && + screen_x < COLS) { + cw = wcwidth(*p); + addwch(*p++); + pp++; + screen_x += cw; + } + } + standend(); + } + break; + } + } + move(1, 0); + refresh(); + return (1); +} + +void +read_result(BUFFER *buf) +{ + int i, st, fds[2]; + pid_t pipe_pid, pid; + FILE *fp; + + /* Clear buffer */ + memset(buf, 0, sizeof(*buf)); + + if (pipe(fds) == -1) + err(EX_OSERR, "pipe()"); + + if ((pipe_pid = vfork()) == -1) + err(EX_OSERR, "vfork()"); + else if (pipe_pid == 0) { + close(fds[0]); + if (fds[1] != STDOUT_FILENO) { + dup2(fds[1], STDOUT_FILENO); + close(fds[1]); + } + if (xflag) { + if (unveil(cmdv[0], "x") == -1) + err(1, "%s: unveil", cmdv[0]); + execvp(cmdv[0], cmdv); + } else { + if (unveil(_PATH_BSHELL, "x") == -1) + err(1, "%s: unveil", _PATH_BSHELL); + execl(_PATH_BSHELL, _PATH_BSHELL, "-c", cmdstr, NULL); + } + + /* use warn(3) + _exit(2) not to call exit(3) */ + warn("exec(%s)", cmdstr); + _exit(EX_OSERR); + + /* NOTREACHED */ + } + if ((fp = fdopen(fds[0], "r")) == NULL) + err(EX_OSERR, "fdopen()"); + close(fds[1]); + + /* Read command output and convert tab to spaces * */ + for (i = 0; i < MAXLINE && fgetws((*buf)[i], MAXCOLUMN, fp) != NULL; + i++) + untabify((*buf)[i], sizeof((*buf)[i])); + fclose(fp); + do { + pid = waitpid(pipe_pid, &st, 0); + } while (pid == -1 && errno == EINTR); + + /* Remember update time */ + time(&lastupdate); + + if (WIFEXITED(st)) + last_exitcode = WEXITSTATUS(st); + if (pause_on_error && last_exitcode) + paused = 1; +} + +kbd_result_t +kbd_command(int ch) +{ + char buf[10], *endp; + double intvl; + + switch (ch) { + + case 'h': + case '?': + show_help(); + refresh(); + return (RSLT_REDRAW); + + /* + * Control-L redraws the screen without executing the command. + */ + /* + * TODO: Redrawing is sometimes needed when programs emit to stderr. + * gnuwatch captures output on stderr a bit nicer, we should + * investigate how to capture & display stdout+stderr in a fashion like + * one'd see it in the shell. + */ + case ctrl('l'): + clear(); + return (RSLT_REDRAW); + + /* + * (Space) re-run the command. + */ + case ' ': + return (RSLT_UPDATE); + + case 'e': + if (pause_on_error == 1) + pause_on_error = 0; + else + pause_on_error = 1; + return (RSLT_REDRAW); + + case 'p': + if ((paused = !paused) != 0) + return (RSLT_REDRAW); + else + return (RSLT_UPDATE); + + case 't': + if (highlight_mode != HIGHLIGHT_NONE) { + last_highlight_mode = highlight_mode; + highlight_mode = HIGHLIGHT_NONE; + } else { + highlight_mode = last_highlight_mode; + } + break; + + case 'c': + if (highlight_mode == HIGHLIGHT_CHAR) + highlight_mode = HIGHLIGHT_NONE; + else + highlight_mode = HIGHLIGHT_CHAR; + break; + + case 'l': + if (highlight_mode == HIGHLIGHT_LINE) + highlight_mode = HIGHLIGHT_NONE; + else + highlight_mode = HIGHLIGHT_LINE; + break; + + case 'w': + if (highlight_mode == HIGHLIGHT_WORD) + highlight_mode = HIGHLIGHT_NONE; + else + highlight_mode = HIGHLIGHT_WORD; + break; + + case 'n': + move(1, 0); + standout(); + printw("New interval: "); + standend(); + + echo(); + getnstr(buf, sizeof(buf)); + noecho(); + + intvl = strtod(buf, &endp); + if (intvl >= 0 && intvl <= 1000000 && *endp == '\0') { + opt_interval.tv_sec = (int)intvl; + opt_interval.tv_usec = + (u_long)(intvl * 1000000UL) % 1000000UL; + } else { + move(1, 0); + standout(); + printw("Interval should be a non-negative number"); + standend(); + refresh(); + return (RSLT_ERROR); + } + + return (RSLT_REDRAW); + + case KEY_DOWN: + case 'j': + start_line = MIN(start_line + 1, MAXLINE - 1); + break; + + case KEY_UP: + case 'k': + start_line = MAX(start_line - 1, 0); + break; + + case 'J': + start_line = MIN(start_line + ((LINES - 2) / 2), MAXLINE - 1); + break; + + case 'K': + start_line = MAX(start_line - ((LINES - 2) / 2), 0); + break; + + case KEY_NPAGE: + start_line = MIN(start_line + (LINES - 2), MAXLINE - 1); + break; + + case KEY_PPAGE: + start_line = MAX(start_line - (LINES - 2), 0); + break; + + case 'g': + start_line = 0; + break; + + case 'G': + start_line = LINES - 1; + break; + + case 'L': + start_column = MIN(start_column + ((COLS - 2) / 2), + MAXCOLUMN - 1); + break; + + case 'H': + start_column = MAX(start_column - ((COLS - 2) / 2), 0); + break; + + case KEY_LEFT: + case '[': + start_column = MAX(start_column - 1, 0); + break; + + case KEY_RIGHT: + case ']': + start_column = MIN(start_column + 1, MAXCOLUMN - 1); + break; + + case 'q': + quit(); + + default: + return (RSLT_ERROR); + + } + + return (RSLT_REDRAW); +} + +void +show_help(void) +{ + int ch; + ssize_t len; + + clear(); + nl(); + + printw("These commands are available:\n" + "\n" + "Movement:\n" + "j | k - scroll down/up one line\n" + "[ | ] - scroll left/right one column\n" + "(arrow keys) - scroll left/down/up/right one line or column\n" + "H | J | K | L - scroll left/down/up/right half a screen\n" + "(Page Down) - scroll down a full screen\n" + "(Page Up) - scroll up a full screen\n" + "g - go to top\n" + "G - go to bottom\n" + "\n" + "Other:\n" + "(Space) - run command again\n" + "c - highlight changed characters\n" + "l - highlight changed lines\n" + "w - highlight changed words\n" + "t - toggle highlight mode on/off\n" + "n - change update interval\n" + "e - pause after non-zero exit\n" + "p - toggle pause / resume\n" + "h | ? - show this message\n" + "q - quit\n\n"); + + standout(); + printw("Hit any key to continue."); + standend(); + refresh(); + + while (1) { + len = read(STDIN_FILENO, &ch, 1); + if (len == -1 && errno == EINTR) + continue; + if (len == 0) + exit(1); + break; + } +} + +void +untabify(wchar_t *buf, int maxlen) +{ + int i, tabstop = 8, len, spaces, width = 0, maxcnt; + wchar_t *p = buf; + + maxcnt = maxlen / sizeof(wchar_t); + while (*p && p - buf < maxcnt - 1) { + if (*p != L'\t') { + width += wcwidth(*p); + p++; + } else { + spaces = tabstop - (width % tabstop); + len = MIN(maxcnt - (p + spaces - buf), + (int)wcslen(p + 1) + 1); + if (len > 0) + memmove(p + spaces, p + 1, + len * sizeof(wchar_t)); + len = MIN(spaces, maxcnt - 1 - (p - buf)); + for (i = 0; i < len; i++) + p[i] = L' '; + p += len; + width += len; + } + } + *p = L'\0'; +} + +void +on_signal(int signum) +{ + quit(); +} + +void +quit(void) +{ + erase(); + refresh(); + endwin(); + free(cmdv); + exit(EXIT_SUCCESS); +} + +static void __dead +usage(void) +{ + fprintf(stderr, "usage: %s [-celwx] [-n seconds] command [arg ...]\n", + getprogname()); + exit(1); +} Index: usr.bin/watch/Makefile =================================================================== RCS file: usr.bin/watch/Makefile diff -N usr.bin/watch/Makefile --- /dev/null 1 Jan 1970 00:00:00 -0000 +++ usr.bin/watch/Makefile 19 May 2025 19:17:31 -0000 @@ -0,0 +1,11 @@ +# $OpenBSD$ + +PROG= watch + +# XXX: why is this needed? +CFLAGS+= -D_XOPEN_SOURCE_EXTENDED + +LDADD+= -lcurses +DPADD+= ${LIBCURSES} + +.include