Skip to content

_cache_eval never converges on a generator that produces nothing — it re-forks every shell, silently #580

Description

@Gerrrt

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:

  1. 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).
  2. 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).
  3. 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions