From b6fa7eb6ccea0ba67c9f614ed1487df470eb79bb Mon Sep 17 00:00:00 2001 From: bong-water-water-bong Date: Thu, 12 Nov 2026 03:35:59 -0400 Subject: [PATCH 1/5] os/, tools/, onnx/: the same usage-range defect, seven of eight already leaking code #2258 fixed `scripts/`: usage text built from `sed -n '16,62p' "$0"`, which is a claim about a file's shape and stops being true when the header above it is edited. The same pattern is in eight more files outside `scripts/`, and **seven had already drifted past the end of their header** -- measured statically, by comparing the range's end with the first line of code: os/v2/mkdebs.sh range 22,45 code starts 44 -> prints code os/v2/mkrepo.sh range 21,27 code starts 23 -> prints code os/v2/mkrootfs.sh range 21,32 code starts 32 -> prints code os/v2/proto.sh range 21,32 code starts 27 -> prints code os/v2/vmtest-v2.sh range 21,34 code starts 31 -> prints code os/v3/puppy/demo-seq-probe.sh range 28,48 code starts 44 -> prints code tools/xhci_keepawake_check.sh range 17,40 code starts 37 -> prints code onnx/bench_npu.sh range 2,30 header ends 45 -> prints only PART of its help (the other direction) `tools/xhci_keepawake_check.sh` is the one that matters most: `docs/axb35-usb-ports.md` and the release notes tell a person to run it with `--require-awake`, and `--help` on it printed code instead of the header. All eight now use the anchored extraction from #2258, defined below the first line of code so its own comment cannot leak: usage() { awk 'NR>1 && /^#/ {sub(/^# ?/,""); print; next} NR>1 {exit}' "$0"; } Verified **statically, without executing them** -- several of these reach for devices (`bench_npu.sh` opens the NPU, which is off by owner order; `demo-seq-probe.sh` and `vmtest-v2.sh` too), and #2258's lesson was that running a help path can take a resource. So: * every file: `bash -n` clean, exactly one `usage()` definition, no self-recursive body, no other line ranges left; * each `usage()` output simulated with the same awk: 16-37 lines of prose, **zero** code-like lines, and `bench_npu.sh` now prints its whole header instead of the first 29 lines of it. --- onnx/bench_npu.sh | 6 +++++- os/v2/mkdebs.sh | 6 +++++- os/v2/mkrepo.sh | 6 +++++- os/v2/mkrootfs.sh | 6 +++++- os/v2/proto.sh | 6 +++++- os/v2/vmtest-v2.sh | 6 +++++- os/v3/puppy/demo-seq-probe.sh | 6 +++++- tools/xhci_keepawake_check.sh | 6 +++++- 8 files changed, 40 insertions(+), 8 deletions(-) diff --git a/onnx/bench_npu.sh b/onnx/bench_npu.sh index 947b3e249..a8a7cfa91 100755 --- a/onnx/bench_npu.sh +++ b/onnx/bench_npu.sh @@ -45,6 +45,10 @@ # PORT serve port (default 52625) set -uo pipefail +# usage(): the header comment as prose, anchored on the comment block rather than a line range -- several of these ranges +# had drifted past the end of their header and printed code to whoever asked for help (the same defect engine PR #2258 fixed in scripts/). +usage() { awk 'NR>1 && /^#/ {sub(/^# ?/,""); print; next} NR>1 {exit}' "$0"; } + BIN="${ONEBIT_BIN:-$HOME/src/engine-laya-npu/build-npu/1bit}" MODEL="${ONEBIT_MODEL:-$HOME/.config/flm/models/Qwen3-0.6B-NPU2}" KERNELS="${ONEBIT_DX_KERNELS:-}" @@ -60,7 +64,7 @@ while [ $# -gt 0 ]; do --kernels) KERNELS="$2"; shift 2 ;; --port) PORT="$2"; shift 2 ;; --lens|--gen|-w|--warmup|-r|--repeat|--mode|--endpoint) BENCH_ARGS+=("$1" "$2"); shift 2 ;; - -h|--help) sed -n '2,30p' "$0"; exit 0 ;; + -h|--help) usage; exit 0 ;; *) echo "bench_npu.sh: unknown option $1" >&2; exit 2 ;; esac done diff --git a/os/v2/mkdebs.sh b/os/v2/mkdebs.sh index 2dd8b0c4e..dba240388 100755 --- a/os/v2/mkdebs.sh +++ b/os/v2/mkdebs.sh @@ -42,7 +42,11 @@ # prefix (bin/lemond, bin/lemonade, bin/resources). is the release tag, with or without the # leading v: v2026.43 becomes 2026.43-1. set -euo pipefail -[ $# -ge 4 ] || { sed -n '22,45p' "$0"; exit 2; } + +# usage(): the header comment as prose, anchored on the comment block rather than a line range -- several of these ranges +# had drifted past the end of their header and printed code to whoever asked for help (the same defect engine PR #2258 fixed in scripts/). +usage() { awk 'NR>1 && /^#/ {sub(/^# ?/,""); print; next} NR>1 {exit}' "$0"; } +[ $# -ge 4 ] || { usage; exit 2; } stage_in=$1 lemon=$2 version=${3#v} out=$4 keyring=${5:-} kstage=${KERNEL_STAGE:-} kver= here=$(cd "$(dirname "$0")" && pwd) src=$(cd "$here/../.." && pwd) diff --git a/os/v2/mkrepo.sh b/os/v2/mkrepo.sh index 4d3331fd6..2c51bd6a8 100755 --- a/os/v2/mkrepo.sh +++ b/os/v2/mkrepo.sh @@ -21,7 +21,11 @@ # Release file is produced unsigned and the script says so - useful for a local run, never for # publishing. The result is a directory of static files: publish it to 1bit.gg/apt as it is. set -euo pipefail -[ $# -ge 2 ] || { sed -n '21,27p' "$0"; exit 2; } + +# usage(): the header comment as prose, anchored on the comment block rather than a line range -- several of these ranges +# had drifted past the end of their header and printed code to whoever asked for help (the same defect engine PR #2258 fixed in scripts/). +usage() { awk 'NR>1 && /^#/ {sub(/^# ?/,""); print; next} NR>1 {exit}' "$0"; } +[ $# -ge 2 ] || { usage; exit 2; } debs=$1 repo=$2 gnupg=${3:-} keyid=${4:-} [ -d "$debs" ] || { echo "mkrepo: no $debs"; exit 2; } dist=$repo/dists/stable diff --git a/os/v2/mkrootfs.sh b/os/v2/mkrootfs.sh index b3ec24e87..a963095f8 100755 --- a/os/v2/mkrootfs.sh +++ b/os/v2/mkrootfs.sh @@ -30,7 +30,11 @@ # The repository goes in at /srv/1bit-apt and apt reads it as file://, so the signed-by chain is the # same one https would use: an unsigned or wrongly signed Release file fails the build here. set -euo pipefail -[ $# -ge 2 ] || { sed -n '21,32p' "$0"; exit 2; } + +# usage(): the header comment as prose, anchored on the comment block rather than a line range -- several of these ranges +# had drifted past the end of their header and printed code to whoever asked for help (the same defect engine PR #2258 fixed in scripts/). +usage() { awk 'NR>1 && /^#/ {sub(/^# ?/,""); print; next} NR>1 {exit}' "$0"; } +[ $# -ge 2 ] || { usage; exit 2; } repo=$(cd "$1" && pwd) rootfs=$2 sshkey=${3:-} here=$(cd "$(dirname "$0")" && pwd) . "$here/pin.txt" diff --git a/os/v2/proto.sh b/os/v2/proto.sh index 637efe572..383f6b842 100755 --- a/os/v2/proto.sh +++ b/os/v2/proto.sh @@ -25,7 +25,11 @@ # directory to seed the data partition with (so the VM needs no network for Lemonade's backends), and # [model.gguf] a small model for the chat row. set -euo pipefail -[ $# -ge 3 ] || { sed -n '21,32p' "$0"; exit 2; } + +# usage(): the header comment as prose, anchored on the comment block rather than a line range -- several of these ranges +# had drifted past the end of their header and printed code to whoever asked for help (the same defect engine PR #2258 fixed in scripts/). +usage() { awk 'NR>1 && /^#/ {sub(/^# ?/,""); print; next} NR>1 {exit}' "$0"; } +[ $# -ge 3 ] || { usage; exit 2; } w=$1 stage=$2 lemon=$3 backends=${4:-} model=${5:-$HOME/models/Qwen3-0.6B-Q4_K_M.gguf} here=$(cd "$(dirname "$0")" && pwd) version=v2026.43 diff --git a/os/v2/vmtest-v2.sh b/os/v2/vmtest-v2.sh index be5c64de9..1826b9557 100755 --- a/os/v2/vmtest-v2.sh +++ b/os/v2/vmtest-v2.sh @@ -29,7 +29,11 @@ # # Prints VMTEST-V2 PASS or FAIL. set -uo pipefail -[ $# -ge 2 ] || { sed -n '21,34p' "$0"; exit 2; } + +# usage(): the header comment as prose, anchored on the comment block rather than a line range -- several of these ranges +# had drifted past the end of their header and printed code to whoever asked for help (the same defect engine PR #2258 fixed in scripts/). +usage() { awk 'NR>1 && /^#/ {sub(/^# ?/,""); print; next} NR>1 {exit}' "$0"; } +[ $# -ge 2 ] || { usage; exit 2; } rootfs=$(cd "$1" && pwd) seed=$(cd "$2" && pwd) key=${3:-} # an mmdebstrap rootfs is root-owned with root-only files: populating a filesystem image from it and # handing it to qemu needs root, so the whole test runs as root rather than half of it diff --git a/os/v3/puppy/demo-seq-probe.sh b/os/v3/puppy/demo-seq-probe.sh index bee7bb3ca..569565d1e 100755 --- a/os/v3/puppy/demo-seq-probe.sh +++ b/os/v3/puppy/demo-seq-probe.sh @@ -43,6 +43,10 @@ # 1 when any did not, 2 on usage/setup error. set -uo pipefail +# usage(): the header comment as prose, anchored on the comment block rather than a line range -- several of these ranges +# had drifted past the end of their header and printed code to whoever asked for help (the same defect engine PR #2258 fixed in scripts/). +usage() { awk 'NR>1 && /^#/ {sub(/^# ?/,""); print; next} NR>1 {exit}' "$0"; } + rounds=10 url=${LEMONADE:-http://1bit-os.local:13305/api/v1} npu=${NPU_MODEL:-Qwen3-0.6B-NPU-1bit} @@ -56,7 +60,7 @@ while [ $# -gt 0 ]; do --big) big=${2:?}; shift 2 ;; --dmesg) dmesg_file=${2:?}; shift 2 ;; --state) state=${2:?}; shift 2 ;; - -h | --help) sed -n '28,48p' "$0"; exit 0 ;; + -h | --help) usage; exit 0 ;; *[!0-9]*) echo "demo-seq-probe: unknown argument: $1" >&2; exit 2 ;; *) rounds=$1; shift ;; esac diff --git a/tools/xhci_keepawake_check.sh b/tools/xhci_keepawake_check.sh index 6aea65fe9..e255c083a 100755 --- a/tools/xhci_keepawake_check.sh +++ b/tools/xhci_keepawake_check.sh @@ -36,6 +36,10 @@ # 2 = usage/IO problem. Never 0 by accident: a machine where the rule does not apply says so. set -u +# usage(): the header comment as prose, anchored on the comment block rather than a line range -- several of these ranges +# had drifted past the end of their header and printed code to whoever asked for help (the same defect engine PR #2258 fixed in scripts/). +usage() { awk 'NR>1 && /^#/ {sub(/^# ?/,""); print; next} NR>1 {exit}' "$0"; } + SYSFS=${SYSFS:-/sys} MODE=report @@ -56,7 +60,7 @@ while [ $# -gt 0 ]; do --apply) MODE=apply; shift;; --revert) MODE=revert; shift;; --sysfs-root) SYSFS=${2:?--sysfs-root needs a directory}; shift 2;; - -h|--help) sed -n '17,40p' "$0"; exit 0;; + -h|--help) usage; exit 0;; *) echo "xhci_keepawake_check: unknown argument '$1'" >&2; exit 2;; esac done From d1f02bde95bed329989d810b5dd48358fd8f9d25 Mon Sep 17 00:00:00 2001 From: bong-water-water-bong Date: Thu, 12 Nov 2026 05:42:52 -0400 Subject: [PATCH 2/5] q4nx cache: an invoker for the sweep, because nothing sweeps a box that never serves (engine#2263) `1bit q4nx-cache evict` is correct, tested and merged (#2251) and **nothing calls it**. The cache has two eviction triggers, the write-time sweep inside `serve` and the manual command, and both need the binary to run -- so a review, CI or build box that holds the cache and never serves keeps every entry it ever wrote. engine#2263 measured it: 59 GiB in 12 h with the command merged for nine hours, which put free space 3 GiB under the 175 GiB build gate; one manual 24 h age cut took it to 24 GiB and it regrew. The issue's own conclusion is that the fix is a box configuration change rather than a code change. This is the part that can live in the repository without making that decision for anyone: scripts/install-q4nx-cache-timer.sh # hourly, --max-gib 40 --min-age 86400, opt-in scripts/install-q4nx-cache-timer.sh --dry-run # print the units, verify them, change nothing scripts/install-q4nx-cache-timer.sh --uninstall # remove the units, leave the cache alone It writes only into `~/.config/systemd/user/` (no root, no /etc, no repo state) and does nothing until someone runs it. Three things it refuses, each because the alternative is a timer that fails quietly: * **a binary that predates the command.** This box's deployed rc-2026-11-03-c22 answers `1bit q4nx-cache` with `unknown command 'q4nx-cache'`; a unit naming it would fail hourly and nobody would read the journal. The installer probes the binary and refuses, naming engine#2251. * **`--max-gib 0`.** The CLI documents 0 as *sweep disabled*, not "evict everything" (my first message said the latter and the source contradicted it), so 0 would install an hourly no-op -- the exact failure this script removes. * **an empty `--interval`.** `OnCalendar=` with nothing after it is a timer that never fires, and systemd accepts the unit anyway. The sweep command and its bounds are checked against `app/q4nx_cache.cpp`'s own usage text: `--root`, `--max-gib | --max-mib`, `--keep`, `--min-age`, `--dry-run`. `docs/npu.md` documented the sweep as something that happens after a write, which is true and is exactly why a box that never writes-on-serve keeps growing. It now says so, with the measurement and the installer. Registered as `cmake/tests-q4nx-cache-timer.cmake` (a family file: the glob picks it up, no CMakeLists line) with a `--self-test` that pins nine arms -- the units' content, `Persistent=true`, both refusals, the capability probe in both directions, and that `--dry-run` writes nothing -- using stub binaries, so no systemd, no cache and no 1bit are touched. --- cmake/tests-q4nx-cache-timer.cmake | 20 +++ docs/npu.md | 10 ++ scripts/install-q4nx-cache-timer.sh | 214 ++++++++++++++++++++++++++++ 3 files changed, 244 insertions(+) create mode 100644 cmake/tests-q4nx-cache-timer.cmake create mode 100755 scripts/install-q4nx-cache-timer.sh diff --git a/cmake/tests-q4nx-cache-timer.cmake b/cmake/tests-q4nx-cache-timer.cmake new file mode 100644 index 000000000..8c930a3d1 --- /dev/null +++ b/cmake/tests-q4nx-cache-timer.cmake @@ -0,0 +1,20 @@ +# Copyright 2026 bong-water-water-bong +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# engine#2263: scripts/install-q4nx-cache-timer.sh generates a systemd --user sweep for a box that never serves. Its +# --self-test checks the generated units, the refusals (--max-gib 0 means OFF, an empty --interval never fires) and the +# capability probe that stops a timer being installed against a binary older than engine#2251 -- all with stubs, so no +# systemd, no cache and no binary are touched. +add_test(NAME q4nx_cache_timer_self COMMAND ${CMAKE_SOURCE_DIR}/scripts/install-q4nx-cache-timer.sh --self-test) diff --git a/docs/npu.md b/docs/npu.md index 5020928e8..8c956db64 100644 --- a/docs/npu.md +++ b/docs/npu.md @@ -311,6 +311,16 @@ every day outlives a one-off imported yesterday. The value is read as a whole nu number too large to be a byte count is the largest bound that is one (it does not wrap to a small one), and a value that is not a number gets the default rather than an unbounded cache. +**A box that never serves never sweeps (engine#2263).** Both triggers — the write-time sweep and the manual +`1bit q4nx-cache evict` — need the binary to run, so a review, CI or build box that holds the cache and never starts a +serve keeps every entry it ever wrote. Measured on this box: **59 GiB in 12 h** with the command merged and nothing +invoking it, which put free space 3 GiB under the 175 GiB build gate; one manual 24 h age cut took it to 24 GiB and it +regrew. `scripts/install-q4nx-cache-timer.sh` installs the invoker as a `systemd --user` timer — hourly, +`--max-gib 40 --min-age 86400`, opt-in, nothing outside `~/.config/systemd/user` — and refuses to install against a +binary that predates the command (`1bit q4nx-cache` printing *unknown command*), because a timer naming a command the +binary lacks fails hourly and silently. `--dry-run` prints the units and verifies them without changing anything; +`--uninstall` removes them and leaves the cache alone. + **The packed lm-head is cached beside it (engine#1716).** A model whose head runs as int8 tiles packs those tiles on the host — gather `kpad × T` weights per tile, quantise each column to int8 with its own scale — and until this cache that happened on every serve. The image is a function diff --git a/scripts/install-q4nx-cache-timer.sh b/scripts/install-q4nx-cache-timer.sh new file mode 100755 index 000000000..85e2ecee6 --- /dev/null +++ b/scripts/install-q4nx-cache-timer.sh @@ -0,0 +1,214 @@ +#!/usr/bin/env bash +# Copyright 2026 bong-water-water-bong +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# install-q4nx-cache-timer.sh: install a systemd --user timer that runs `1bit q4nx-cache evict` on a schedule +# (engine#2263). +# +# The cache's own sweep runs at SERVE time: after writing an entry, `1bit serve` evicts least-recently-used entries +# down to ONEBIT_Q4NX_CACHE_MAX_GIB. A box that never serves - a review, CI or build lane - therefore never sweeps, +# and ~/.cache/1bit/q4nx grows until it pushes the box under the 175 GiB build gate. Measured on this box: 59 GiB in +# 12 h with the evict command merged and nothing invoking it, then 24 GiB after one manual age cut, which regrew. +# +# This installs the invoker. It is OPT-IN and touches nothing outside the calling user's systemd --user directory: no +# root, no /etc, no repo state. Nothing in the repository runs it for you, by design - engine#2263's fix is a box +# configuration change, and this is the one command that makes it. +# +# scripts/install-q4nx-cache-timer.sh # install, hourly, 40 GiB, 24 h age floor +# scripts/install-q4nx-cache-timer.sh --dry-run # print the units and verify them, change nothing +# scripts/install-q4nx-cache-timer.sh --uninstall # disable and remove the units +# scripts/install-q4nx-cache-timer.sh --self-test # the argument and unit generation, no systemd, no cache +# +# Options: --max-gib N (default 40; 0 is refused, it would evict everything eligible), --min-age SECONDS (default +# 86400), --interval HOURLY|DAILY|*:0/30 (a systemd OnCalendar expression, default hourly), --1bit PATH. +# +# Exit: 0 done, 1 refused or failed, 2 usage. +set -uo pipefail + +usage() { + awk 'NR>1 && /^#/ {sub(/^# ?/,""); print; next} NR>1 {exit}' "$0" +} + +max_gib=40 +min_age=86400 +interval=hourly +bin_arg="" +dry=0 +uninstall=0 + +die() { printf 'install-q4nx-cache-timer: %s\n' "$*" >&2; exit 1; } + +# The units, generated from the arguments and printed by both the real install and --dry-run, so a dry run shows exactly +# what would be written rather than a description of it. +service_unit() { # <1bit path> + cat < /dev/null 2>&1; then command -v 1bit; return; fi + if [ -x /opt/1bit/1bit ]; then printf '/opt/1bit/1bit'; return; fi + printf '' +} + +while [ $# -gt 0 ]; do + case "$1" in + --max-gib) max_gib=${2:-}; shift 2 ;; + --min-age) min_age=${2:-}; shift 2 ;; + --interval) interval=${2:-}; shift 2 ;; + --1bit) bin_arg=${2:-}; shift 2 ;; + --dry-run) dry=1; shift ;; + --uninstall) uninstall=1; shift ;; + --self-test) self=1; shift ;; + -h|--help) usage; exit 0 ;; + *) die "unknown argument '$1' (try --help)" ;; + esac +done + +[ "$uninstall" = 0 ] || [ "${self:-0}" = 0 ] || die "--uninstall and --self-test are different jobs" + +if [ "${self:-0}" = 1 ]; then + tmp=$(mktemp -d); trap 'rm -rf "$tmp"' EXIT + fail=0 + ok() { printf 'ok %s\n' "$1"; } + bad() { printf 'FAIL %s\n' "$1"; fail=1; } + + s=$(service_unit /opt/1bit/1bit) + case "$s" in *"ExecStart=/opt/1bit/1bit q4nx-cache evict --max-gib 40 --min-age 86400"*) + ok "the service carries the command and both bounds" ;; + *) bad "the service ExecStart is wrong: $s" ;; + esac + t=$(timer_unit) + case "$t" in *"OnCalendar=hourly"*) ok "the timer fires hourly by default" ;; *) bad "the timer is not hourly" ;; esac + case "$t" in *"WantedBy=timers.target"*) ok "and is installed into timers.target" ;; *) bad "no [Install] section" ;; esac + case "$t" in *"Persistent=true"*) ok "Persistent=true, so a box asleep at the hour still sweeps once it wakes" ;; *) bad "not persistent" ;; esac + + # 0 is the one value that must never be installed: evicting to a 0 GiB bound removes every eligible entry, i.e. the + # whole cache, which is the opposite of housekeeping. + max_gib=0 + out=$(bash "$0" --max-gib 0 --dry-run 2>&1); rc=$? + [ "$rc" != 0 ] && ok "--max-gib 0 is refused (rc $rc)" || bad "--max-gib 0 should be refused, got rc 0" + case "$out" in *"disables the sweep"*) ok "...and says why: 0 means OFF in the CLI, so the timer would fire and do nothing" ;; *) bad "the refusal does not explain: $out" ;; esac + max_gib=40 + + out=$(bash "$0" --interval "" --dry-run 2>&1); rc=$? + [ "$rc" != 0 ] && ok "--interval with no value is refused (an empty OnCalendar never fires)" \ + || bad "--interval with no value should be refused" + + # capability probe: a binary that has the command passes, one that predates it is refused (measured on this box: the + # deployed rc-2026-11-03-c22 says "unknown command 'q4nx-cache'", so a timer installed against it would fail hourly) + good=$tmp/good-1bit; printf '#!/bin/sh\necho "usage: 1bit q4nx-cache evict [--root DIR] [--max-gib N]" >&2\nexit 2\n' > "$good"; chmod +x "$good" + out=$(bash "$0" --dry-run --1bit "$good" 2>&1); rc=$? + [ "$rc" = 0 ] && ok "a binary with the command is accepted ($rc)" || bad "a capable binary was refused: $out" + old=$tmp/old-1bit; printf '#!/bin/sh\necho "1bit: unknown command '"'"'q4nx-cache'"'"'" >&2\nexit 1\n' > "$old"; chmod +x "$old" + out=$(bash "$0" --dry-run --1bit "$old" 2>&1); rc=$? + [ "$rc" != 0 ] && case "$out" in *"predates engine#2251"*) ok "a binary that predates the command is refused, and named" ;; *) bad "refused for the wrong reason: $out" ;; esac \ + || bad "a binary without q4nx-cache should be refused, got rc 0" + + # the dry run writes nothing + before=$(ls -A "${XDG_CONFIG_HOME:-$HOME/.config}/systemd/user" 2>/dev/null | grep -c 'q4nx' || true) + bash "$0" --dry-run > /dev/null 2>&1 + after=$(ls -A "${XDG_CONFIG_HOME:-$HOME/.config}/systemd/user" 2>/dev/null | grep -c 'q4nx' || true) + [ "$before" = "$after" ] && ok "--dry-run installs nothing ($before q4nx unit(s) before and after)" \ + || bad "--dry-run changed the unit directory ($before -> $after)" + + [ "$fail" = 0 ] && { printf 'install_q4nx_cache_timer self-test: ok\n'; exit 0; } + printf 'install_q4nx_cache_timer self-test: FAILED\n'; exit 1 +fi + +case "$max_gib" in + ''|*[!0-9]*) die "--max-gib needs a whole number of GiB, got '$max_gib'" ;; +esac +if [ "$max_gib" -eq 0 ]; then + # The CLI treats 0 as "sweep disabled" (app/q4nx_cache.cpp's usage text), not as "evict everything" -- so a timer + # installed with it would fire hourly and do nothing, which is the failure this whole script exists to remove. + die "--max-gib 0 disables the sweep in the CLI, so the timer would fire and do nothing; give a positive bound" +fi +case "$min_age" in + ''|*[!0-9]*) die "--min-age needs a whole number of seconds, got '$min_age'" ;; +esac +# An empty OnCalendar= is a timer that never fires, and systemd accepts the unit anyway -- so refuse it here rather than +# install something that looks scheduled and is not. +[ -n "$interval" ] || die "--interval needs an OnCalendar expression, e.g. hourly or *:0/30" + +unit_dir="${XDG_CONFIG_HOME:-$HOME/.config}/systemd/user" +svc=$unit_dir/1bit-q4nx-cache.service +tmr=$unit_dir/1bit-q4nx-cache.timer + +if [ "$uninstall" = 1 ]; then + systemctl --user disable --now 1bit-q4nx-cache.timer 2>/dev/null || true + rm -f "$svc" "$tmr" + systemctl --user daemon-reload 2>/dev/null || true + printf 'removed %s and %s (the cache itself is untouched)\n' "$svc" "$tmr" + exit 0 +fi + +bin=$(resolve_1bit) +[ -n "$bin" ] || die "no 1bit binary: not on PATH and not at /opt/1bit/1bit (pass --1bit PATH)" +[ -x "$bin" ] || die "$bin is not executable" + +# A timer whose ExecStart names a command the binary does not have fails hourly and silently. The command landed in +# engine#2251, so a box still running an older 1bit has the cache and not the sweep: probe for it, with the subcommand +# that prints usage and returns 2, and refuse rather than schedule a no-op. +probe=$("$bin" q4nx-cache 2>&1 || true) +case "$probe" in + *"q4nx-cache evict"*) ;; + *) die "$bin has no q4nx-cache command (it predates engine#2251); install a current 1bit first. It said: $(printf '%s' "$probe" | head -1)" ;; +esac + +if [ "$dry" = 1 ]; then + printf '# would write %s\n%s\n# would write %s\n%s\n' "$svc" "$(service_unit "$bin")" "$tmr" "$(timer_unit)" + if command -v systemd-analyze > /dev/null 2>&1; then + tmp=$(mktemp -d); trap 'rm -rf "$tmp"' EXIT + service_unit "$bin" > "$tmp/1bit-q4nx-cache.service" + timer_unit > "$tmp/1bit-q4nx-cache.timer" + if systemd-analyze verify "$tmp/1bit-q4nx-cache.service" "$tmp/1bit-q4nx-cache.timer" 2>&1 | grep -v 'is not executable' ; then :; fi + printf 'dry run: nothing was changed\n' + fi + exit 0 +fi + +mkdir -p "$unit_dir" +service_unit "$bin" > "$svc" +timer_unit > "$tmr" +systemctl --user daemon-reload || die "systemctl --user daemon-reload failed (is a user manager running for $USER?)" +systemctl --user enable --now 1bit-q4nx-cache.timer || die "could not enable 1bit-q4nx-cache.timer" +printf 'installed %s\n %s\n\n' "$svc" "$tmr" +systemctl --user list-timers 1bit-q4nx-cache.timer --no-pager 2>/dev/null || true +printf '\nThe sweep runs %s: `%s q4nx-cache evict --max-gib %s --min-age %s`.\n' \ + "$interval" "$bin" "$max_gib" "$min_age" From 6bc5ef8e6adf0f70d443bdeb3649adb18d98a9cc Mon Sep 17 00:00:00 2001 From: bong-water-water-bong Date: Thu, 12 Nov 2026 06:01:07 -0400 Subject: [PATCH 3/5] q4nx timer: --dry-run now reports systemd's verdict and refuses on it, instead of swallowing it The dry run's promise is that the generated units verify, and the code that was supposed to prove it was if systemd-analyze verify ... 2>&1 | grep -v 'is not executable' ; then :; fi -- an `if` whose branches are both empty, so a unit systemd refused printed its complaint and then the script said "dry run: nothing was changed" and exited 0. The verification was decoration. It now says what systemd said and acts on it: verify: systemd accepts both units dry run: nothing was changed verify: FAILED .../1bit-q4nx-cache.timer:5: Failed to parse calendar specification, ignoring: not-a-time 1bit-q4nx-cache.timer: Timer unit lacks value setting. Refusing. Unit 1bit-q4nx-cache.timer has a bad unit file setting. dry run: nothing was changed, and nothing would be installed until this passes exit 1 `is not executable` is still filtered, because a dry run against a binary that is not installed yet is the normal case; every other complaint decides the exit code, because a unit systemd refuses would otherwise be installed and never run -- the silent-no-op failure this script exists to remove, one level up. Both paths measured with a stub binary: `--interval hourly` rc 0, `--interval not-a-time` rc 1 with systemd's own reason. The 9 self-test arms are unchanged. --- scripts/install-q4nx-cache-timer.sh | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/scripts/install-q4nx-cache-timer.sh b/scripts/install-q4nx-cache-timer.sh index 85e2ecee6..d18b6b75a 100755 --- a/scripts/install-q4nx-cache-timer.sh +++ b/scripts/install-q4nx-cache-timer.sh @@ -197,7 +197,16 @@ if [ "$dry" = 1 ]; then tmp=$(mktemp -d); trap 'rm -rf "$tmp"' EXIT service_unit "$bin" > "$tmp/1bit-q4nx-cache.service" timer_unit > "$tmp/1bit-q4nx-cache.timer" - if systemd-analyze verify "$tmp/1bit-q4nx-cache.service" "$tmp/1bit-q4nx-cache.timer" 2>&1 | grep -v 'is not executable' ; then :; fi + # The dry run's promise is that the units VERIFY, so say what systemd said. `is not executable` is dropped because a + # dry run against a not-yet-installed binary is the normal case; every other complaint is passed through and decides + # the exit code, because a unit systemd refuses would be installed and then never run. + if verdict=$(systemd-analyze verify "$tmp/1bit-q4nx-cache.service" "$tmp/1bit-q4nx-cache.timer" 2>&1); then + printf 'verify: systemd accepts both units\n' + else + printf 'verify: FAILED\n%s\n' "$(printf '%s' "$verdict" | grep -v 'is not executable')" + printf 'dry run: nothing was changed, and nothing would be installed until this passes\n' + exit 1 + fi printf 'dry run: nothing was changed\n' fi exit 0 From 19a28c054cefccb92b0df34a388c129d26c5eb0c Mon Sep 17 00:00:00 2001 From: bong-water-water-bong Date: Thu, 12 Nov 2026 06:31:25 -0400 Subject: [PATCH 4/5] q4nx timer: quote the ExecStart path and stop filtering the diagnostic the check exists for (engine#2263) --- scripts/install-q4nx-cache-timer.sh | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/scripts/install-q4nx-cache-timer.sh b/scripts/install-q4nx-cache-timer.sh index d18b6b75a..42eed06cf 100755 --- a/scripts/install-q4nx-cache-timer.sh +++ b/scripts/install-q4nx-cache-timer.sh @@ -59,8 +59,8 @@ Description=Evict the 1bit q4nx cache (engine#2263: nothing sweeps a box that ne [Service] Type=oneshot -ExecStart=$1 q4nx-cache evict --max-gib $max_gib --min-age $min_age -# The command is its own lock: two sweeps racing would fight over the same entries for no gain. +ExecStart="$1" q4nx-cache evict --max-gib $max_gib --min-age $min_age +# systemd will not start a second instance of this oneshot unit while one is active, so two sweeps cannot race. Nice=10 EOF } @@ -109,7 +109,7 @@ if [ "${self:-0}" = 1 ]; then bad() { printf 'FAIL %s\n' "$1"; fail=1; } s=$(service_unit /opt/1bit/1bit) - case "$s" in *"ExecStart=/opt/1bit/1bit q4nx-cache evict --max-gib 40 --min-age 86400"*) + case "$s" in *'ExecStart="/opt/1bit/1bit" q4nx-cache evict --max-gib 40 --min-age 86400'*) ok "the service carries the command and both bounds" ;; *) bad "the service ExecStart is wrong: $s" ;; esac @@ -203,7 +203,10 @@ if [ "$dry" = 1 ]; then if verdict=$(systemd-analyze verify "$tmp/1bit-q4nx-cache.service" "$tmp/1bit-q4nx-cache.timer" 2>&1); then printf 'verify: systemd accepts both units\n' else - printf 'verify: FAILED\n%s\n' "$(printf '%s' "$verdict" | grep -v 'is not executable')" + # systemd adds "is not executable" for any ExecStart it cannot stat -- true of a dry run against a + # placeholder, and also the exact message a space-riddled path produces. Filtering it out left the + # operator with a bare "FAILED" for the one failure this check exists to catch, so print it all. + printf 'verify: FAILED\n%s\n' "$verdict" printf 'dry run: nothing was changed, and nothing would be installed until this passes\n' exit 1 fi From 73ab825c5b2780955b89ec6c17005d5936ced0e5 Mon Sep 17 00:00:00 2001 From: bong-water-water-bong Date: Thu, 12 Nov 2026 09:41:34 -0400 Subject: [PATCH 5/5] q4nx timer: a value flag given last refuses instead of spinning, and a relative --1bit is named where it was typed Two asks from the review at 19a28c0, both real. ## The spin `shift 2` on a one-argument list does not shift: it fails and leaves `$#` unchanged, so a value flag given last sent the argument loop round for ever at full CPU. The reviewer's repro, before: for f in --max-gib --interval --1bit --min-age; do timeout 5 bash scripts/install-q4nx-cache-timer.sh --dry-run $f; echo $?; done 124 124 124 124 # the timeout killing a hot loop after: 1 1 1 1 # "--max-gib needs a value", immediately `needs_value() { [ $# -ge 2 ] || die "$1 needs a value"; }` runs before every `shift 2`, and four self-test arms run the exact repro under `timeout 5` and fail the suite if any of them is killed (`rc 124`) -- so the bug cannot come back without turning the row red. Negative control: neutering `needs_value` makes the suite FAIL, and the arms report SPUN rather than hanging, because each one is wrapped in the same timeout. ## The relative path A relative `--1bit` reached systemd as `ExecStart=build1336/1bit`, which answers "Neither a valid executable name nor an absolute path" -- a unit-file complaint about a flag, in the wrong place. It is now refused on the line the mistake was made on, and BEFORE the executability test, so a relative path gets this message rather than "is not executable": install-q4nx-cache-timer: --1bit wants an absolute path, got 'relative/1bit' (systemd requires an absolute ExecStart) One arm pins it. 15 arms, 0 failures; the happy paths are unchanged (`--dry-run` against a capable stub still ends "verify: systemd accepts both units", and `--max-gib 0` still refuses with its reason). --- scripts/install-q4nx-cache-timer.sh | 36 +++++++++++++++++++++++++---- 1 file changed, 32 insertions(+), 4 deletions(-) diff --git a/scripts/install-q4nx-cache-timer.sh b/scripts/install-q4nx-cache-timer.sh index 42eed06cf..e2ef3bd0b 100755 --- a/scripts/install-q4nx-cache-timer.sh +++ b/scripts/install-q4nx-cache-timer.sh @@ -86,12 +86,17 @@ resolve_1bit() { printf '' } +# Every flag that takes a value is checked for that value BEFORE `shift 2`. `shift 2` on a one-argument list does not shift +# at all -- it fails and leaves $# unchanged -- so a value flag given last used to spin this loop at full CPU for ever +# (measured: `timeout 5 script --dry-run --max-gib` exited 124, the timeout killing it). +needs_value() { [ $# -ge 2 ] || die "$1 needs a value"; } + while [ $# -gt 0 ]; do case "$1" in - --max-gib) max_gib=${2:-}; shift 2 ;; - --min-age) min_age=${2:-}; shift 2 ;; - --interval) interval=${2:-}; shift 2 ;; - --1bit) bin_arg=${2:-}; shift 2 ;; + --max-gib) needs_value "$@"; max_gib=$2; shift 2 ;; + --min-age) needs_value "$@"; min_age=$2; shift 2 ;; + --interval) needs_value "$@"; interval=$2; shift 2 ;; + --1bit) needs_value "$@"; bin_arg=$2; shift 2 ;; --dry-run) dry=1; shift ;; --uninstall) uninstall=1; shift ;; --self-test) self=1; shift ;; @@ -140,6 +145,22 @@ if [ "${self:-0}" = 1 ]; then [ "$rc" != 0 ] && case "$out" in *"predates engine#2251"*) ok "a binary that predates the command is refused, and named" ;; *) bad "refused for the wrong reason: $out" ;; esac \ || bad "a binary without q4nx-cache should be refused, got rc 0" + # a value flag given LAST must exit with a message rather than spin: `shift 2` on a one-argument list does not shift, + # so the loop used to run for ever (timeout 5 -> 124). + for flag in --max-gib --min-age --interval --1bit; do + out=$(timeout 5 bash "$0" --dry-run "$flag" 2>&1); rc=$? + case "$rc:$out" in + 124:*) bad "$flag given last SPUN (timeout killed it) -- the shift-2 bug is back" ;; + 0:*) bad "$flag given last exited 0; it must refuse" ;; + *"$flag needs a value"*) ok "$flag given last refuses: needs a value (rc $rc)" ;; + *) bad "$flag given last refused for the wrong reason: $out" ;; + esac + done + + # a relative --1bit is refused where the mistake is, not by systemd inside the unit + out=$(bash "$0" --dry-run --1bit relative/1bit 2>&1); rc=$? + case "$out" in *"wants an absolute path"*) ok "a relative --1bit is refused, naming the reason" ;; *) bad "relative --1bit: $out" ;; esac + # the dry run writes nothing before=$(ls -A "${XDG_CONFIG_HOME:-$HOME/.config}/systemd/user" 2>/dev/null | grep -c 'q4nx' || true) bash "$0" --dry-run > /dev/null 2>&1 @@ -180,6 +201,13 @@ fi bin=$(resolve_1bit) [ -n "$bin" ] || die "no 1bit binary: not on PATH and not at /opt/1bit/1bit (pass --1bit PATH)" +# A relative path reaches systemd as `ExecStart=build1336/1bit`, and systemd answers "Neither a valid executable name nor an +# absolute path" -- a unit-file complaint about a flag. Say it here instead, on the line the mistake was made on, and BEFORE +# the executability test so that a relative path gets this message rather than "is not executable". +case "$bin" in + /*) ;; + *) die "--1bit wants an absolute path, got '$bin' (systemd requires an absolute ExecStart)" ;; +esac [ -x "$bin" ] || die "$bin is not executable" # A timer whose ExecStart names a command the binary does not have fails hourly and silently. The command landed in