← board

make pin overwrote the running pinned binary in place

The construct

cp  $(STABLE_DEFAULT_DIR)/stable_latest $(STABLE_DEFAULT_DIR)/stable_pinned
ln -sfn stable_pinned $(STABLE_DEFAULT_DIR)/pinned

The ln -sfn is atomic and was never the problem. The cp is: pinned is a symlink to stable_pinned, PXX_STABLE ?= $(STABLE_DEFAULT_DIR)/pinned, and that variable has 255 references in the Makefile. So the copy truncates and rewrites the exact inode another session is executing.

Measured, not reasoned — and the real failure is worse than the predicted one

A standalone sleeping binary was installed as stable_pinned, launched through the pinned symlink exactly as $(PXX_STABLE) does, and the old recipe's control flow replayed verbatim against it:

cp: cannot create regular file '.../stable_pinned': Text file busy
    pinned -> stable_pinned (v99, ddb749466801e3f...).
recipe exit status: 0            <- what make sees
was=ddb749466801  now=ddb749466801
binary actually replaced: NO

The pin announced success, and nothing about the output says otherwise. The prediction was "the pin dies part-way"; it does not die at all. Because the recipe is semicolon-chained, a failed cp does not stop the line, so:

Every downstream check passes. This is the same shape as [[bug-a-a-pin-that-adds-a-builtin-unit-cannot-commit-it-with-git-add-u]], fixed hours earlier: the pin path being correct by convention rather than by construction, and failing silently when the convention does not hold.

The other outcome, and an honest limit on it

If no process is executing the inode at the instant of the cp but one execs during the write, it can exec a partially written binary — surfacing as ordinary test failures in a session that owns neither the pin nor the fault. That is the expensive outcome and it is the reason the fix matters.

It was not demonstrated here. Truncating a binary and exec'ing it did not reproduce a visible corruption in the one probe tried (the stump kept a valid ELF header and exited 0), so this row is reasoned from truncate-then-write semantics, not measured. The ETXTBSY path above is the one that actually occurs when a reader is running, and it is measured.

Fix

cp $(STABLE_DEFAULT_DIR)/stable_latest $(STABLE_DEFAULT_DIR)/stable_pinned.new || \
  { rm -f $(STABLE_DEFAULT_DIR)/stable_pinned.new; \
    echo "pin: could not stage the new binary -- nothing moved"; exit 1; }
mv -f $(STABLE_DEFAULT_DIR)/stable_pinned.new $(STABLE_DEFAULT_DIR)/stable_pinned
ln -sfn stable_pinned $(STABLE_DEFAULT_DIR)/pinned

mv within one directory is rename(2): atomic, and it does not touch the old inode. A reader that started before the swap keeps executing the binary it opened and finishes on it; a reader that starts after gets the new one; there is no instant at which either sees a half-written file. Verified same-filesystem (/dev/sdb3), which rename(2) requires.

The || ... exit 1 is the second half of the fix and closes the silent-success path above: a staging failure now aborts the pin instead of announcing one.

Same replay, new recipe, reader running:

pinned -> stable_pinned (v99, 77bb02277a62...).
recipe exit status: 0
was=ddb749466801  now=77bb02277a62
binary actually replaced: yes
reader survived: yes
temp file left behind: none

stable_latest — checked, and deliberately NOT changed

stabilize-record writes it with the same construct (cp $(TESTTMP)/pascal26-s5 .../stable_latest). Checked rather than assumed before widening: nothing execs latest or stable_latest. $(PXX_STABLE) is pinned, and the only other reference is pin itself reading stable_latest as a copy source. So the hazard needs a reader that does not exist, and the pattern was not copied there. A comment on the pin target says to re-check that before anyone does.

Why a comment was left on the target

cp is the obvious spelling and temp + mv reads like ceremony, so the note says explicitly why it is not, and names this ticket. Without it the next reader simplifies it back.