From 353152e62bc6812955a2b61e1a5e4a00464a5807 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=85smund=20=C3=98deg=C3=A5rd?= Date: Sun, 9 Aug 2026 21:40:19 +0200 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20Add=20sleep=20tool=20for=20Windows?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Windows ships no sleep(1), so goal's `run"sleep""2"` fails on %PATH% lookup. The usual substitutes are all unusable here: `timeout /t` is integer-only and aborts with "Input redirection is not supported" whenever stdin is not a console, `ping -n` abuses the network stack for integer seconds, and PowerShell's Start-Sleep costs a few hundred ms of interpreter startup per call. A 13 KB C binary avoids all of that and adds fractional seconds plus the GNU s/m/h/d suffixes. The POSIX nanosleep path exists only so the source builds and can be tested off Windows. --- .gitignore | 1 + README | 51 ++++++++++++++-- shell/make.sh | 14 +++-- sleep/Makefile | 15 +++++ sleep/build.sh | 46 ++++++++++++++ sleep/sleep.c | 97 ++++++++++++++++++++++++++++++ specs/009-sleep-command/spec.md | 103 ++++++++++++++++++++++++++++++++ 7 files changed, 318 insertions(+), 9 deletions(-) create mode 100644 sleep/Makefile create mode 100755 sleep/build.sh create mode 100644 sleep/sleep.c create mode 100644 specs/009-sleep-command/spec.md diff --git a/.gitignore b/.gitignore index e4c2d24..517fcec 100644 --- a/.gitignore +++ b/.gitignore @@ -3,4 +3,5 @@ shell/gitprompt-linux-amd64 shell/gitprompt-linux-arm64 shell/gitprompt-windows-arm64.exe shell/pass +shell/sleep */*.exe diff --git a/README b/README index 4a7ed09..a7c56b9 100644 --- a/README +++ b/README @@ -135,12 +135,55 @@ sudo cp shell/gitprompt /usr/local/bin/ --- +## sleep - Windows sleep(1) + +A minimal GNU-`sleep`-alike for Windows, which ships no `sleep` of its own. +Written in C, ~13 KB, no runtime dependencies. + +Motivation: `timeout /t N` is integer-only and fails with "Input redirection +is not supported" whenever stdin is not a console — for example when called +from the [goal](https://codeberg.org/anaseto/goal) interpreter's `run` +builtin. See `specs/009-sleep-command/spec.md`. + +### Build +```bash +cd sleep && ./build.sh # native build → ../shell/sleep[.exe] +cd sleep && ./build.sh clean +``` + +### Install +```bash +# Windows: copy sleep.exe to a directory on %PATH% +copy shell\sleep.exe C:\Dev\bins\ + +# Linux/macOS: not needed, coreutils already provides sleep +``` + +### Usage +```bash +sleep 2 # 2 seconds +sleep 0.25 # fractional seconds +sleep 1.5s # explicit suffix +sleep 2m # minutes; also h (hours), d (days) +sleep 1m 30 # arguments are summed → 90 seconds +``` + +From goal: +```k +run"sleep""0.25" +``` + +### Prerequisites +- A C compiler (`gcc`, `clang`); override with `CC=...` + +--- + ## Building All Tools -Use the wrapper script to build both tools at once: +Use the wrapper script to build all tools at once: ```bash -./shell/make.sh # build both gitprompt and pass +./shell/make.sh # build gitprompt, pass and sleep ./shell/make.sh build # same -./shell/make.sh build-all # cross-compile both -./shell/make.sh clean # clean both builds +./shell/make.sh build-all # cross-compile (sleep is native-only) +./shell/make.sh clean # clean all builds ``` diff --git a/shell/make.sh b/shell/make.sh index 2542ccd..edac81c 100755 --- a/shell/make.sh +++ b/shell/make.sh @@ -1,12 +1,12 @@ #!/bin/sh -# Wrapper script to build both gitprompt and pass. +# Wrapper script to build all tools: gitprompt, pass and sleep. # # Usage: -# ./make.sh # build both tools +# ./make.sh # build all tools # ./make.sh build # same -# ./make.sh build-all # cross-compile both tools -# ./make.sh clean # clean both builds -# ./make.sh update-deps # update dependencies for both +# ./make.sh build-all # cross-compile (sleep is native-only) +# ./make.sh clean # clean all builds +# ./make.sh update-deps # update dependencies where applicable set -eu @@ -25,11 +25,15 @@ case "$cmd" in echo "Building pass..." (cd "$REPO_ROOT/pass" && ./build.sh "$cmd") + + echo "Building sleep..." + (cd "$REPO_ROOT/sleep" && ./build.sh "$cmd") ;; install) echo "Install to $PREFIX" cp $REPO_ROOT/shell/pass$EXT $PREFIX/bin cp $REPO_ROOT/shell/gitprompt$EXT $PREFIX/bin + cp $REPO_ROOT/shell/sleep$EXT $PREFIX/bin ;; *) echo "Usage: $0 [build|build-all|clean|update-deps]" >&2 diff --git a/sleep/Makefile b/sleep/Makefile new file mode 100644 index 0000000..2be3e69 --- /dev/null +++ b/sleep/Makefile @@ -0,0 +1,15 @@ +OUTDIR := ../shell +CC ?= gcc +CFLAGS ?= -O2 -Wall -Wextra +LDFLAGS ?= -s + +.PHONY: build build-all clean + +build: + $(CC) $(CFLAGS) $(LDFLAGS) -o $(OUTDIR)/sleep sleep.c + +# Cross-compilation is not wired up for the C tool; native only. +build-all: build + +clean: + rm -f $(OUTDIR)/sleep $(OUTDIR)/sleep.exe diff --git a/sleep/build.sh b/sleep/build.sh new file mode 100755 index 0000000..0b94567 --- /dev/null +++ b/sleep/build.sh @@ -0,0 +1,46 @@ +#!/bin/sh +# Build helper for the sleep tool (C, no Go toolchain required). +# Equivalent to the targets in ./Makefile. +# +# Usage: +# ./build.sh # native build → ../shell/sleep[.exe] +# ./build.sh build # same +# ./build.sh clean +# +# Requires a C compiler (cc/gcc/clang). Override with CC=... + +set -eu + +OUTDIR=../shell +CC=${CC:-gcc} +CFLAGS=${CFLAGS:--O2 -Wall -Wextra} +LDFLAGS=${LDFLAGS:--s} + +# Resolves to ".exe" on Windows, "" elsewhere. +case "$(uname -s)" in + MINGW*|MSYS*|CYGWIN*|*NT*) EXT=.exe ;; + *) EXT= ;; +esac + +cmd=${1:-build} + +case "$cmd" in + build) + mkdir -p "$OUTDIR" + $CC $CFLAGS $LDFLAGS -o "$OUTDIR/sleep$EXT" sleep.c + ;; + clean) + rm -f "$OUTDIR/sleep" "$OUTDIR/sleep.exe" + ;; + update-deps) + # No dependencies; present so ./shell/make.sh can pass the target through. + ;; + build-all) + # Cross-compilation is not wired up for the C tool; native only. + "$0" build + ;; + *) + echo "Usage: $0 [build|build-all|update-deps|clean]" >&2 + exit 1 + ;; +esac diff --git a/sleep/sleep.c b/sleep/sleep.c new file mode 100644 index 0000000..3ec9174 --- /dev/null +++ b/sleep/sleep.c @@ -0,0 +1,97 @@ +/* sleep - minimal GNU-sleep-alike, primarily for Windows. + * + * Usage: sleep NUMBER[smhd]... + * Sleeps for the sum of all arguments. NUMBER may be fractional. + * Suffixes: s = seconds (default), m = minutes, h = hours, d = days. + * + * Rationale: Windows ships no `sleep`. `timeout /t N` is integer-only and + * fails with "Input redirection is not supported" when stdin is not a + * console, which is exactly the case when invoked from goal's `run` (or any + * other process that pipes stdin). This provides a real executable on %PATH% + * so `run"sleep""0.25"` works. + * + * Build: see ./build.sh (or ./Makefile). + */ +#include +#include +#include + +#ifdef _WIN32 +#include +#else +#include +#include +#endif + +static void usage(void) { + fputs("usage: sleep NUMBER[smhd]...\n", stderr); +} + +/* Sleep for `seconds`, chunked so long waits cannot overflow a DWORD of ms. */ +static void sleep_seconds(double seconds) { + while (seconds > 0.0) { + double chunk = seconds > 1000.0 ? 1000.0 : seconds; +#ifdef _WIN32 + Sleep((DWORD)(chunk * 1000.0 + 0.5)); +#else + struct timespec ts; + ts.tv_sec = (time_t)chunk; + ts.tv_nsec = (long)((chunk - (double)ts.tv_sec) * 1e9); + while (nanosleep(&ts, &ts) == -1 && errno == EINTR) { + /* resume remaining time */ + } +#endif + seconds -= chunk; + } +} + +/* Parse one interval argument. Returns 0 on success, -1 on bad input. */ +static int parse_interval(const char *arg, double *out) { + char *end = NULL; + double v = strtod(arg, &end); + + if (end == arg) { + return -1; + } + switch (*end) { + case '\0': break; + case 's': end++; break; + case 'm': v *= 60.0; end++; break; + case 'h': v *= 3600.0; end++; break; + case 'd': v *= 86400.0; end++; break; + default: + return -1; + } + if (*end != '\0' || v < 0.0) { + return -1; + } + *out = v; + return 0; +} + +int main(int argc, char **argv) { + double total = 0.0; + int i; + + if (argc < 2) { + usage(); + return 2; + } + + for (i = 1; i < argc; i++) { + double v; + + if (strcmp(argv[i], "--help") == 0 || strcmp(argv[i], "-h") == 0) { + usage(); + return 0; + } + if (parse_interval(argv[i], &v) != 0) { + fprintf(stderr, "sleep: invalid time interval '%s'\n", argv[i]); + return 1; + } + total += v; + } + + sleep_seconds(total); + return 0; +} diff --git a/specs/009-sleep-command/spec.md b/specs/009-sleep-command/spec.md new file mode 100644 index 0000000..a46bb39 --- /dev/null +++ b/specs/009-sleep-command/spec.md @@ -0,0 +1,103 @@ +# Windows `sleep` Command Specification + +## Overview + +This specification describes a small C utility, `sleep`, that provides a +GNU-`sleep`-compatible subset on Windows. Its primary consumer is the +[goal](https://codeberg.org/anaseto/goal) interpreter's `run` builtin, which +cannot delay execution because Windows ships no `sleep` executable. + +## Status + +- **Status**: Implemented ✅ +- **Created**: 2026-08-09 +- **Branch**: `feat/009-sleep-command` + +## Background + +In goal, shell-outs look like: + +```k +run"curl""-V" +``` + +`run` execs the named program directly (it does not go through `cmd.exe`, +although Go's `LookPath` semantics mean `PATHEXT` entries such as `.cmd` are +resolved). On Windows there is no `sleep` on `%PATH%`, so: + +```k +run"sleep""2" +``` + +fails with a "not available on %PATH%" style error. + +## Problem Statement + +The obvious Windows substitutes are all inadequate: + +| Candidate | Problem | +|---|---| +| `timeout /t N` | Integer seconds only, and aborts with `ERROR: Input redirection is not supported` when stdin is not a console — precisely the case under `run`. | +| `ping -n N 127.0.0.1` | Integer seconds only; abuses the network stack; off-by-one semantics. | +| `powershell Start-Sleep` | ~200-400 ms of interpreter startup overhead per call. | +| `.cmd` shim | Resolvable via `PATHEXT`, but only wraps one of the above. | + +## Goals + +- Provide a real `sleep.exe` on `%PATH%` usable from goal's `run`. +- Support fractional seconds (`sleep 0.25`). +- Support the GNU suffixes `s`, `m`, `h`, `d`. +- Sum multiple arguments, as GNU `sleep` does (`sleep 1m 30`). +- Negligible startup overhead; no runtime dependencies. + +## Non-Goals + +- Full GNU coreutils compatibility (no `--version`, no locale handling). +- Cross-compilation. Unix platforms already ship `sleep`; the POSIX code path + exists only so the source builds and can be tested anywhere. + +## Design + +Single translation unit, `sleep/sleep.c`: + +- `parse_interval()` — `strtod` plus an optional single-character suffix. + Rejects trailing garbage and negative values. +- `sleep_seconds()` — `Sleep()` on Windows, `nanosleep()` (with `EINTR` + resume) elsewhere. Chunked at 1000 s so long waits cannot overflow the + `DWORD` millisecond argument. +- Arguments are summed before sleeping. + +### Interface + +``` +usage: sleep NUMBER[smhd]... +``` + +| Exit code | Meaning | +|---|---| +| 0 | Slept successfully, or `--help`/`-h` | +| 1 | Invalid time interval | +| 2 | No arguments given | + +Errors are written to stderr as `sleep: invalid time interval ''`. + +## Build & Install + +Follows the existing per-tool convention (`gitprompt`, `pass`): a `build.sh` +and `Makefile` that emit into `../shell/`. + +```sh +cd sleep && ./build.sh # → ../shell/sleep[.exe] +./shell/make.sh # builds all tools, including sleep +``` + +Install by copying `shell/sleep.exe` to a directory on `%PATH%`. + +## Acceptance Criteria + +- [x] `goal -e 'say run"sleep""2"'` waits ~2 s and exits cleanly. +- [x] `goal -e 'run"sleep""0.25"'` honours fractional seconds. +- [x] `sleep 1m 30` sums to 90 s. +- [x] `sleep abc` prints an error and exits 1. +- [x] `sleep` with no arguments prints usage and exits 2. +- [x] `./shell/make.sh clean` removes the built binary.