Follow-up from #449 / #578. Pre-existing in zsh/00-tools.zsh; noticed while moving four
generator calls into Core.
The defect
_cache_eval (zsh/00-tools.zsh) decides whether to regenerate on -s — "cache is
non-empty":
if [[ ! -s "$cache" || "$bin" -nt "$cache" ]]; then
[[ -d "$dir" ]] || mkdir -p "$dir"
"$@" >|"$cache" 2>/dev/null
fi
source "$cache"
If the generator exits 0 but writes nothing to stdout, the cache is created 0 bytes. -s
then fails forever, so the next shell regenerates too — and the next, and the next. The
cache never converges. Every interactive shell pays a fork for a tool that is permanently
un-cached, and there is no symptom: no error, no output, nothing in the prompt.
Reproduced
run 1: cache exists=yes size=0 regenerates_next_time=YES
run 2: cache exists=yes size=0 regenerates_next_time=YES
run 3: cache exists=yes size=0 regenerates_next_time=YES
(driving _cache_eval against a stub that exits 0 and prints nothing.)
Why it is worth fixing now
The trigger is a renamed or removed generator subcommand, and _cache_eval gained three
new callers in #578 — one of which is ty, Astral's pre-1.0 type checker. ty generate-shell-completion
is exactly the kind of CLI surface that gets renamed before 1.0. Note the failure is silent
because 2>/dev/null is deliberate and correct there: a generator's chatter must not be
sourced into the shell. So the "command not found"-shaped signal is discarded by design, and
the empty file is all that is left.
Same applies to a generator that fails after truncation — >| empties the cache before the
command runs, so a tool that errors out mid-write leaves a partial or empty file. The partial
case is arguably worse: -s passes and the shell sources a truncated init.
Possible shapes
Deliberately not picking one — each trades differently:
- Generate to a temp file, install only on success and non-empty. Fixes both the empty
and the truncated case, and leaves the last good cache in place when a generator breaks.
Costs one more file operation per regeneration (not per shell).
- Write a sentinel comment on empty output so
-s passes and the shell stops re-forking.
Cheapest, but caches a failure — a tool fixed upstream would stay un-completed until the
binary's mtime moves (which, admittedly, an upgrade does).
- Check the exit status and skip the cache write when non-zero. Does not catch the
exits-0-prints-nothing case, which is the one actually observed.
(1) looks right, and it is also what makes a truncated cache impossible. Whichever is chosen,
scripts/test-core.sh should gain fixtures for: generator prints nothing + exits 0; generator
prints nothing + exits non-zero; generator prints a partial script then fails.
Severity
Low impact, and benign for the user — a fork per shell per broken tool. Filed because it is
invisible by construction and the new ty caller is the most likely thing to trip it.
Follow-up from #449 / #578. Pre-existing in
zsh/00-tools.zsh; noticed while moving fourgenerator calls into Core.
The defect
_cache_eval(zsh/00-tools.zsh) decides whether to regenerate on-s— "cache isnon-empty":
If the generator exits 0 but writes nothing to stdout, the cache is created 0 bytes.
-sthen fails forever, so the next shell regenerates too — and the next, and the next. The
cache never converges. Every interactive shell pays a fork for a tool that is permanently
un-cached, and there is no symptom: no error, no output, nothing in the prompt.
Reproduced
(driving
_cache_evalagainst a stub that exits 0 and prints nothing.)Why it is worth fixing now
The trigger is a renamed or removed generator subcommand, and
_cache_evalgained threenew callers in #578 — one of which is
ty, Astral's pre-1.0 type checker.ty generate-shell-completionis exactly the kind of CLI surface that gets renamed before 1.0. Note the failure is silent
because
2>/dev/nullis deliberate and correct there: a generator's chatter must not besourced into the shell. So the "command not found"-shaped signal is discarded by design, and
the empty file is all that is left.
Same applies to a generator that fails after truncation —
>|empties the cache before thecommand runs, so a tool that errors out mid-write leaves a partial or empty file. The partial
case is arguably worse:
-spasses and the shell sources a truncated init.Possible shapes
Deliberately not picking one — each trades differently:
and the truncated case, and leaves the last good cache in place when a generator breaks.
Costs one more file operation per regeneration (not per shell).
-spasses and the shell stops re-forking.Cheapest, but caches a failure — a tool fixed upstream would stay un-completed until the
binary's mtime moves (which, admittedly, an upgrade does).
exits-0-prints-nothing case, which is the one actually observed.
(1) looks right, and it is also what makes a truncated cache impossible. Whichever is chosen,
scripts/test-core.shshould gain fixtures for: generator prints nothing + exits 0; generatorprints nothing + exits non-zero; generator prints a partial script then fails.
Severity
Low impact, and benign for the user — a fork per shell per broken tool. Filed because it is
invisible by construction and the new
tycaller is the most likely thing to trip it.