llama-cli prints the error and exits 0. The same binary exits 1 when the model is missing.
Point llama-cli at a file that isn’t there:
$ llama-cli -m gemma-3-1b-it-Q4_K_M.gguf \
--audio /tmp/does-not-exist.wav \
-p test -n 1 --no-warmup --simple-io --single-turn
...
Error: file does not exist or cannot be opened: '/tmp/does-not-exist.wav'
...
$ echo $?
0
It printed the error and returned success. Both of those are true at the same time, and only one of them is visible to the script that called it.
It is not that the exit status is meaningless here
That was my first assumption and it is wrong, which matters, because “this tool doesn’t do exit codes properly” is a much less useful thing to know than what is actually happening. Three controls, same binary, same machine:
# Missing media file
$ llama-cli -m model.gguf --audio /tmp/does-not-exist.wav ... ; echo $?
Error: file does not exist or cannot be opened: '/tmp/does-not-exist.wav'
0
# Missing image, to check this isn't audio-specific
$ llama-cli -m model.gguf --image /tmp/nope.png ... ; echo $?
Error: file does not exist or cannot be opened: '/tmp/nope.png'
0
# Same missing file, through llama-mtmd-cli instead
$ llama-mtmd-cli -m model.gguf --audio /tmp/does-not-exist.wav ... ; echo $?
1
# Missing *model*, through llama-cli
$ llama-cli -m /tmp/nope.gguf ... ; echo $?
gguf_init_from_file: failed to open GGUF file '/tmp/nope.gguf'
1
The same binary returns 1 for a missing model and 0 for a missing media
file. So the machinery to fail is present and works; one specific path does
not use it. llama-mtmd-cli returning 1 on the identical input rules out the
other easy explanation — this is not a limitation of what the media loader can
express upward.
What the code actually does
From tools/cli/cli-context.cpp on master. The staging function reports failure
honestly and says nothing:
bool cli_context::stage_media_file(const std::string & fname, const std::string & type) {
std::ifstream file(fname, std::ios::binary);
if (!file) {
return false;
}
Its caller — the loop that handles media given on the command line — prints the message I saw, and then leaves the loop:
for (auto & fname : params.image) {
if (!stage_media_file(fname, media_type_from_ext(fname))) {
ui::show_error(string_format("file does not exist or cannot be opened: '%s'", fname.c_str()));
break;
}
ui::show_message(string_format("Loaded media from '%s'", fname.c_str()));
}
buffer = params.prompt;
break ends the media loop and nothing else. The next statement continues
into ordinary prompt handling, which is why the model still answered my prompt
in the run above — it answered it without the audio I asked for, having told me
so in a line that changed nothing.
And the function containing all this has exactly one exit:
int cli_context::run() {
...
return 0; // the only return in the function
}
There is no path through run() that returns non-zero. Not “there is one
and this case misses it” — there isn’t one.
Where the upstream report and this disagree
The issue — open,
bug-unconfirmed, no comments, no fix — attributes this to run() discarding
the return value of generate_completion(). That is a real defect: the function
is declared bool, and its caller does not look at the result.
But it is not what fires here. A missing file on the command line is handled before generation starts, breaks out of the media loop, and never reaches that call. Generation then runs normally and succeeds.
So there are two independent routes to the same symptom — a staging failure
that breaks without propagating, and a generation failure whose return value is
dropped — and they converge on a run() that cannot report failure either way.
Fixing only the one named in the report would leave the case in the report’s own
reproduction command still returning 0.
What I verified and what I didn’t
Reproduced on Linux x86_64, build b10703, in a throwaway container, with a
text-only model — no multimodal model is needed, because the failure happens
while staging the file, before anything looks at what it contains. The upstream
report is from Darwin arm64, build 10706. Different OS, different
architecture, different build, same behaviour.
I did not verify the generation-failure route by triggering it; I read it in the source. I did not run the reporter’s zero-byte-PNG cases. And I have not checked whether any of this differs on macOS beyond taking the report at its word.
If you call this from a script
Until it is fixed, llama-cli’s exit status does not tell you whether your media
was loaded. Check the thing you actually care about instead:
out=$(llama-cli -m model.gguf --image "$img" -p "$prompt" ... 2>&1) || exit 1
if grep -q 'does not exist or cannot be opened' <<<"$out"; then
echo "media failed to load" >&2
exit 1
fi
Grepping output is a bad interface and I would rather not recommend it. It is the only signal that is currently correct.
llama-mtmd-cli returns 1 on the same input, so if your use is multimodal
anyway, it is the better call today — not because it is a workaround, but
because it is the tool that reports what happened.
The generalisable habit
The error text and the exit status came from two different places in the
program, and nothing keeps them consistent. ui::show_error is a print. return
0 is a claim. A tool can do both in the same run, and every layer above it —
your shell, your set -e, your CI step, your retry loop — is reading only the
second one.
So the habit is narrow: when you automate a tool, verify that its failure
actually reaches its exit status, using a case you know is broken. Point it at
a file that does not exist and read $?. It takes one command, and it is the
only way to find out whether the thing your automation depends on is load-
bearing or decorative.
The wider version is about which artefact you trust. A message on the terminal is written for a human who is watching. An exit status is the only thing a program downstream can see. When those two disagree, the human sees a failure and the machine sees success — and the pipeline keeps running on the machine’s version.
Toolkit
This post's fix is available as a tested, ready-to-run script in the toolkit.
See the toolkit →