Skip to content

Commit acf466d

Browse files
committed
[install] Parse arguments, and bound the binary check
Two failures found by typing `./install-server.sh --help` at a machine with a working install. There was no argument parsing at all, so --help — and every typo — fell through to the install path. It rebuilt the bundle and, on a machine able to sign, would have replaced a working one. A typo is not consent, so an unknown flag is now an error and there is a real --help. Then the install wedged. The step that proves the installed binary runs executed it with no bound, and a bundle can block in dyld before reaching main — Gatekeeper assessment on a bundle in /Applications does exactly that (#25). Ten minutes with no output, no error and no service. "Does not run" and "does not finish" are different failures and only the first was handled; a verification step that can wedge is worse than none. run_bounded prefers timeout(1) but falls back to background-and-poll, because a stock macOS ships neither timeout nor gtimeout — depending on coreutils would leave the check unbounded on exactly the machines it protects. It sits above the source guard so the tests can reach it; the same mistake with render_plists made every test report "command not found". Solves: --help performing an install, and an unbounded verification step Tests: eleven cases — help and unknown flags touch no filesystem and exit 0/2, the bounded helper is actually used, a timeout reports differently from a broken binary, and run_bounded kills a hanging command and reports 124 both with coreutils and on the fallback
1 parent 427b0fe commit acf466d

2 files changed

Lines changed: 217 additions & 6 deletions

File tree

install-server.sh

Lines changed: 100 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -360,15 +360,86 @@ PLIST
360360
log "Rendered both plists (tacet: ${BIND}:${PORT}, whisper: 127.0.0.1:${WHISPER_PORT})"
361361
}
362362

363+
# Run a command with a wall-clock bound, printing its output and returning its
364+
# status — or 124 if it had to be killed. Defined ABOVE the source guard so the
365+
# tests can exercise it directly; the same mistake with render_plists made every
366+
# test report "command not found".
367+
#
368+
# Prefers a real timeout(1) when one is installed (coreutils), otherwise falls
369+
# back to background-and-poll. The fallback is the point: a stock macOS has
370+
# neither timeout(1) nor gtimeout, so depending on coreutils would leave this
371+
# silently unbounded on exactly the machines it protects.
372+
run_bounded() {
373+
local secs="$1"; shift
374+
if command -v timeout >/dev/null 2>&1; then
375+
timeout "$secs" "$@"
376+
return $?
377+
fi
378+
if command -v gtimeout >/dev/null 2>&1; then
379+
gtimeout "$secs" "$@"
380+
return $?
381+
fi
382+
local out_file pid waited=0 rc
383+
out_file="$(mktemp)"
384+
"$@" >"$out_file" 2>&1 &
385+
pid=$!
386+
while kill -0 "$pid" 2>/dev/null; do
387+
if (( waited >= secs )); then
388+
kill -9 "$pid" 2>/dev/null || true
389+
wait "$pid" 2>/dev/null || true
390+
rm -f "$out_file"
391+
return 124
392+
fi
393+
sleep 1
394+
waited=$((waited + 1))
395+
done
396+
wait "$pid"
397+
rc=$?
398+
cat "$out_file"
399+
rm -f "$out_file"
400+
return $rc
401+
}
402+
363403
# line only runs when the script is executed directly.
364404
if [[ "${BASH_SOURCE[0]}" != "$0" ]]; then
365405
return 0
366406
fi
367407

368-
if [[ "${1:-}" == "--doctor" ]]; then
369-
run_doctor
370-
exit $?
371-
fi
408+
usage() {
409+
cat <<'EOS'
410+
tacet — server setup.
411+
412+
Run this on the Mac that transcribes. It installs Tacet.app, downloads the
413+
whisper model, renders both launchd plists from ~/.config/tacet/config.toml,
414+
and loads the services.
415+
416+
./install-server.sh install or update
417+
./install-server.sh --doctor read-only diagnosis, changes nothing
418+
./install-server.sh --help this message
419+
420+
Environment:
421+
TACET_APP_DIR install the bundle here instead of ~/Applications
422+
TACET_ALLOW_ADHOC build without a Developer ID (local dev only)
423+
424+
Safe to re-run: every step checks current state first.
425+
EOS
426+
}
427+
428+
# Argument handling is explicit, and an unknown flag is an ERROR rather than
429+
# something to ignore. Falling through to "install" meant `--help` performed a
430+
# real installation — it rebuilt the bundle, and on a machine that could sign
431+
# it would have replaced a working one. A typo should not install anything.
432+
case "${1:-}" in
433+
--doctor) run_doctor; exit $? ;;
434+
-h|--help) usage; exit 0 ;;
435+
"") ;; # no arguments: install
436+
*)
437+
err "unknown option: $1"
438+
echo >&2
439+
usage >&2
440+
exit 2
441+
;;
442+
esac
372443

373444
# ==============================================================================
374445
# Install
@@ -454,10 +525,33 @@ fi
454525
# Captured, not piped: `tacet` with no arguments prints usage and exits 2 —
455526
# correct behaviour — and under `set -o pipefail` that makes the pipeline fail
456527
# no matter what grep says, so the check condemned a working binary.
457-
usage_out="$("$APP_DST/Contents/MacOS/tacet" 2>&1 || true)"
528+
#
529+
# BOUNDED, because "does not run" and "does not finish" are different failures
530+
# and only one of them used to be handled. A bundle can block in dyld before
531+
# reaching main — Gatekeeper assessment on a bundle in /Applications does
532+
# exactly this (issue #25) — and an unbounded check then hangs the installer
533+
# forever with no output, no error, and no service. A verification step that
534+
# can wedge is worse than no verification step.
535+
# run_bounded is defined above the source guard so the tests can reach it.
536+
537+
# `tacet` with no arguments exits 2 by design, so a non-zero status here is
538+
# expected and only the OUTPUT decides. 124 is the one status that means
539+
# something different: the timeout fired and nothing can be concluded.
540+
set +e
541+
usage_out="$(run_bounded 20 "$APP_DST/Contents/MacOS/tacet" 2>&1)"
542+
verify_rc=$?
543+
set -e
544+
545+
if [[ "$verify_rc" -eq 124 ]]; then
546+
err "installed ${APP_DST} but it did not finish starting within 20s."
547+
err "The process blocks before reaching main — nothing it logs will say so."
548+
err "A bundle in /Applications does this under Gatekeeper assessment (#25);"
549+
err "installing to ~/Applications (no cask, no TACET_APP_DIR) is known-good."
550+
exit 1
551+
fi
458552
if [[ "$usage_out" != *"usage: tacet"* ]]; then
459553
err "installed ${APP_DST} but the binary does not run — aborting."
460-
err "got: ${usage_out}"
554+
err "got: ${usage_out:-<no output>}"
461555
exit 1
462556
fi
463557
log "Installed."

tests/test_install_server_args.py

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
"""install-server.sh's argument handling, and the bounded verification step.
2+
3+
Both of these were found the same way on 2026-08-05: by typing
4+
`./install-server.sh --help` at a machine with a working install, which
5+
performed a real installation, rebuilt the bundle, and then wedged for ten
6+
minutes on a binary that never finished starting.
7+
8+
Neither failure was visible. The install printed progress and blocked; the
9+
flag printed nothing to say it had been misread.
10+
"""
11+
12+
import subprocess
13+
from pathlib import Path
14+
15+
import pytest
16+
17+
SCRIPT = Path(__file__).resolve().parent.parent / "install-server.sh"
18+
19+
20+
def run(*args: str, home: Path) -> subprocess.CompletedProcess:
21+
return subprocess.run(
22+
["bash", str(SCRIPT), *args],
23+
capture_output=True,
24+
text=True,
25+
env={"PATH": "/usr/bin:/bin", "HOME": str(home)},
26+
)
27+
28+
29+
class TestArgumentHandling:
30+
def test_help_prints_usage_and_installs_nothing(self, tmp_path):
31+
# The original bug: no argument parsing at all, so every unrecognised
32+
# flag fell through to the install path.
33+
r = run("--help", home=tmp_path)
34+
assert r.returncode == 0
35+
assert "install or update" in r.stdout
36+
assert not any(tmp_path.iterdir()), \
37+
f"--help touched the filesystem: {list(tmp_path.iterdir())}"
38+
39+
def test_short_help_works_too(self, tmp_path):
40+
r = run("-h", home=tmp_path)
41+
assert r.returncode == 0
42+
assert "--doctor" in r.stdout
43+
44+
def test_an_unknown_flag_is_an_error_not_an_install(self, tmp_path):
45+
r = run("--bogus", home=tmp_path)
46+
assert r.returncode == 2, "a typo must not be interpreted as consent"
47+
assert "unknown option" in r.stderr
48+
assert not any(tmp_path.iterdir()), \
49+
f"an unknown flag touched the filesystem: {list(tmp_path.iterdir())}"
50+
51+
def test_the_usage_text_documents_the_env_overrides(self, tmp_path):
52+
# TACET_APP_DIR is the documented escape hatch when a cask install is
53+
# not the bundle you want (issue #25), so it has to be discoverable.
54+
r = run("--help", home=tmp_path)
55+
assert "TACET_APP_DIR" in r.stdout
56+
assert "TACET_ALLOW_ADHOC" in r.stdout
57+
58+
59+
class TestVerificationIsBounded:
60+
"""The installed binary is executed to prove it works. That must not hang.
61+
62+
A bundle can block in dyld before reaching main — Gatekeeper assessment on
63+
a bundle in /Applications does exactly that (#25). Unbounded, the installer
64+
waits forever: no output, no error, no service, nothing in any log.
65+
"""
66+
67+
def test_the_binary_check_goes_through_the_bounded_helper(self):
68+
code = SCRIPT.read_text()
69+
# The bare form is the bug. Anything invoking the installed binary for
70+
# verification has to go through run_bounded.
71+
assert 'run_bounded 20 "$APP_DST/Contents/MacOS/tacet"' in code
72+
assert 'usage_out="$("$APP_DST/Contents/MacOS/tacet"' not in code, \
73+
"the unbounded form is back"
74+
75+
def test_a_timeout_is_reported_differently_from_a_broken_binary(self):
76+
# "did not run" and "did not finish" need different messages: the
77+
# second one has no output to show, and the fix is different.
78+
code = SCRIPT.read_text()
79+
assert 'verify_rc" -eq 124' in code, "the timeout status must be handled"
80+
81+
def test_the_fallback_exists_because_macos_ships_no_timeout(self):
82+
# A stock macOS has neither timeout(1) nor gtimeout. Relying on
83+
# coreutils would leave the check silently unbounded on exactly the
84+
# machines it protects.
85+
code = SCRIPT.read_text()
86+
bounded = code[code.index("run_bounded() {"):]
87+
for tool in ("timeout", "gtimeout"):
88+
assert f'command -v {tool}' in bounded
89+
assert "kill -9" in bounded, "the fallback must actually enforce the bound"
90+
assert "return 124" in bounded, "the fallback must report a timeout as 124"
91+
92+
93+
class TestRunBoundedBehaviour:
94+
"""Exercise run_bounded itself, both with and without coreutils."""
95+
96+
def _call(self, tmp_path, command: str, *, with_timeout: bool):
97+
# PATH without /opt/homebrew means no timeout(1) — the fallback path.
98+
path = "/opt/homebrew/bin:/usr/bin:/bin" if with_timeout else "/usr/bin:/bin"
99+
# `set +e` after sourcing: install-server.sh sets errexit, so a 124
100+
# return would kill this shell before it could report the status. The
101+
# real call site wraps the invocation the same way, for the same reason.
102+
program = f'source {SCRIPT}\nset +e\nrun_bounded 3 {command}\necho "rc=$?"\n'
103+
return subprocess.run(
104+
["bash", "-c", program], capture_output=True, text=True,
105+
env={"PATH": path, "HOME": str(tmp_path)},
106+
).stdout
107+
108+
@pytest.mark.parametrize("with_timeout", [True, False], ids=["coreutils", "fallback"])
109+
def test_a_hanging_command_is_killed_and_reported_as_124(self, tmp_path, with_timeout):
110+
out = self._call(tmp_path, "/bin/sleep 30", with_timeout=with_timeout)
111+
assert "rc=124" in out, out
112+
113+
@pytest.mark.parametrize("with_timeout", [True, False], ids=["coreutils", "fallback"])
114+
def test_a_fast_command_returns_its_own_output_and_status(self, tmp_path, with_timeout):
115+
out = self._call(tmp_path, "/bin/echo hello", with_timeout=with_timeout)
116+
assert "hello" in out
117+
assert "rc=0" in out, out

0 commit comments

Comments
 (0)