Engineering4 min read

"zsh: killed" on macOS After Re-downloading a Binary: the Code Signature Cache, and the One-Line Fix

A provider re-ran our Mac install command and every launch of the agent died instantly with "zsh: killed". The binary was fine, the same file ran on other Macs, and there was no crash log. The cause is how macOS caches code signatures per file: curl -o over an existing executable leaves one the kernel refuses to start. Here is how to recognise it, why it happens, and the fix for your install scripts.

The Paralon capybara at a Mac, holding a small file with a broken wax seal while a sealed copy glows on the desk

A GPU provider sent us this, from a Mac with an M4 Max:

$ TOKEN=node_… ~/.paralon/paralon-agent install
zsh: killed     TOKEN=node_… ~/.paralon/paralon-agent install
$ ~/.paralon/paralon-agent logs
zsh: killed     ~/.paralon/paralon-agent logs

Every invocation, killed before it printed a line. Even logs, which does nothing but run tail -f. No crash report in Console, nothing in our logs, because the process never lived long enough to open a socket.

We checked the obvious first. The binary in the release was intact: 6,678,354 bytes, the size curl had reported on their machine, arm64 Mach-O, ad-hoc code signature present, SHA-256 matching the published one. Three other Macs were running that exact file. So the file was fine and the machine could run it. What could make the kernel refuse?

What "zsh: killed" means

killed is the shell reporting that the process received SIGKILL. When it happens at launch, before main() runs, it is not your program crashing; it is the kernel declining to execute the file. On Apple Silicon the usual reason is code signing: every executable must carry a valid signature, even an ad-hoc one, and a binary whose signature does not match its contents is killed on exec.

The important detail is how macOS checks that. The signature is validated per file, and the result is cached and tied to the file's identity on disk, its vnode. Modify the file's bytes without creating a new file, and the cached verdict and the contents disagree. The kernel does not re-verify; it kills.

How the provider got there

They had installed the agent before. Our one-line install did this:

curl -L https://…/paralon-agent-darwin-arm64 -o ~/.paralon/paralon-agent
chmod +x ~/.paralon/paralon-agent

curl -o onto a path that already exists opens the existing file and overwrites its contents in place. Same inode, new bytes. The downloaded binary was perfect; the file it was written into was no longer the file the kernel had validated. Every launch after that: killed.

Our agent's own self-update never hit this, which is why we had not seen it. It downloads to a temporary file and renames it over the old one. A rename swaps in a new inode with its own fresh signature check. Same bytes, different outcome.

Confirm it in ten seconds

codesign -vv ~/.paralon/paralon-agent

A healthy binary prints valid on disk and satisfies its Designated Requirement. One in this state prints an error about the signature or the resources being modified. And for completeness, that the file is what you think it is:

file ~/.paralon/paralon-agent          # Mach-O 64-bit executable arm64
shasum -a 256 ~/.paralon/paralon-agent # compare with the published hash

If the hash matches the release and codesign still complains, it is this problem, not a corrupt download.

The fix

For the machine in front of you:

rm -f ~/.paralon/paralon-agent
curl -L https://…/paralon-agent-darwin-arm64 -o ~/.paralon/paralon-agent
chmod +x ~/.paralon/paralon-agent

Delete, then download. The new file is a new inode and gets a clean verification.

For your install script, so nobody hits it again, either delete before downloading, or download to a temporary name and mv it into place:

curl -L "$URL" -o "$DEST.tmp" && chmod +x "$DEST.tmp" && mv "$DEST.tmp" "$DEST"

Ours now does the first, and also stops a running agent before touching the file:

mkdir -p ~/.paralon && (~/.paralon/paralon-agent stop 2>/dev/null; rm -f ~/.paralon/paralon-agent) \
  && curl -L … -o ~/.paralon/paralon-agent && chmod +x ~/.paralon/paralon-agent

The change is in the command every provider copies from their dashboard, so a second install of the Mac agent is now the same as a first one.

When it is not this

Two lookalikes, quickly ruled out:

  • bad CPU type in executable: an arm64 binary on an Intel Mac. A different message, and uname -m says x86_64.
  • Gatekeeper's dialog ("cannot be opened because the developer cannot be verified"): that is for apps launched from Finder, or files carrying the quarantine attribute, which a curl download does not set. It shows a window, not killed.

And if codesign -vv is clean, the hash matches, and it is still killed, look at endpoint security software on the machine; some of it kills unsigned or ad-hoc-signed binaries by policy, and the symptom is identical.

The general lesson

Never overwrite a running or previously verified executable in place on macOS. curl -o, cp onto the target, cat > file, dd: all keep the inode. rm then write, or write elsewhere then mv: both create a new one. It costs nothing and it removes an entire class of "works on my Mac" support tickets.

Keep reading

Related Articles

The Paralon capybara holding one glowing cable that splits toward three machines: a Linux tower with graphics cards, a Windows laptop, and a Mac
Engineering
10 min

One Model Name, Three Engines: vLLM on Linux, vLLM on WSL and llama.cpp on Apple Silicon Behind One Endpoint

The same model ID is now served by vLLM on Linux GPUs, vLLM on Windows/WSL, and llama.cpp on Apple Silicon Macs, and the caller cannot tell which one answered. Here is what had to be made equal for that to be true, the tokens-per-second we measured on an M1 Ultra and an M4 Max next to the GPUs, the 27B experiment that failed first and the rules it left behind, and a self-update bug that only exists when your inference server is a child process.

inferencellama.cppvllm
Three GPU nodes on a black field, each receiving a different emerald configuration path, with capacity bars of unequal height beneath them
Engineering
10 min

One Config Is Always the Weakest Card's Config

A mixed GPU fleet has one setting per model, and it has to be safe on the slowest machine in it. We measured what that costs: an RTX 5090 running at 8% of its memory bandwidth because a 24GB card three racks away needed a flag. Two arguments, applied only to the cards that could take them, made it fifteen times faster. Here is what the engine measures per node, what it changes, and the two things that surprised us.

engineeringbenchmarksinfrastructure