Found during review of #240, which fixes the same defect class for status / diff / reconcile's drift walk. This one is in internal/render and is not fixed by that PR.
os.ReadFile on a FIFO does not fail — it blocks in the open waiting for a writer that never comes — so the read's own error path never runs and the command never returns.
Measured
Built at d4fb233 (i.e. with #240's gate in place), clean apply, then the rendered destination ~/.claude/skills/demo/SKILL.md replaced by a 0600 FIFO, run from a neutral cwd:
| command |
rc |
status |
0 |
diff |
0 |
reconcile --auto-safe |
0 |
apply --dry-run |
124 — hung |
apply |
124 — hung |
reconcile --auto-override |
124 — hung |
apply --dry-run is advertised as read-only.
reconcile --auto-override (and the interactive [o] keystroke) belongs here rather than in #240: [o] re-applies the item through render.NewWriter → Apply → the same unguarded read, so it shares apply's bug rather than the drift walk's. #240 therefore withholds [o]verride from writeBackFileItem's refusal message — recommending it would route a user out of a clean refusal into this wedge. When this issue is fixed, that suggestion can be restored (there is a test asserting the message does not currently offer it, which will need updating in the same commit).
Where
internal/render/writer.go:200 — the convergence read, the first statement of Writer.Write:
if cur, err := os.ReadFile(op.Path); err == nil && bytes.Equal(cur, finalBytes) {
Its own comment notes "The same read drives the dry-run preview: it is what lets apply --dry-run label a converged destination 'synced' rather than 'write'." So both the real apply and the preview go through it.
A false doc claim to fix alongside
internal/render/writer.go, the doc comment on isRegularOrAbsent, currently says:
… without this, apply --dry-run (advertised as read-only) and the real apply's pre-delete read would both hang forever on a FIFO left at a destination path.
The predicate exists and is correct, but Writer.Write's convergence read does not call it, so the claim about apply --dry-run is false. Whatever fixes the hang should make that sentence true rather than merely plausible.
Suggested shape
Guard the convergence read with the package's own isRegularOrAbsent. On a non-regular destination the convergence check should be skipped rather than attempted — falling through to the normal write path, whose atomic temp-then-rename replaces the FIFO with a real file, which is the outcome the user wants from apply. --dry-run must report rather than block.
internal/render/state_apply.go:310 and :353 also read op.Path directly; they run after the write, so they are likely reached only once the path is regular again, but they should be checked as part of this.
Test note
internal/cli/dest_fifo_e2e_unix_test.go already carries ready-made rows for apply, apply --dry-run and reconcile --auto-override, currently t.Skipped citing this issue. Deleting the skip: string on each is enough to inherit the assertion — they were verified to fail with the correct BLOCKED diagnostic (8s per row, both destination shapes) when un-skipped against the current code.
Any regression test must be timeout-bounded. This class does not fail, it hangs, so a plain call wedges the suite with no diagnostic.
Found during review of #240, which fixes the same defect class for
status/diff/reconcile's drift walk. This one is ininternal/renderand is not fixed by that PR.os.ReadFileon a FIFO does not fail — it blocks in the open waiting for a writer that never comes — so the read's own error path never runs and the command never returns.Measured
Built at
d4fb233(i.e. with #240's gate in place), clean apply, then the rendered destination~/.claude/skills/demo/SKILL.mdreplaced by a0600FIFO, run from a neutral cwd:statusdiffreconcile --auto-safeapply --dry-runapplyreconcile --auto-overrideapply --dry-runis advertised as read-only.reconcile --auto-override(and the interactive[o]keystroke) belongs here rather than in #240:[o]re-applies the item throughrender.NewWriter→Apply→ the same unguarded read, so it sharesapply's bug rather than the drift walk's. #240 therefore withholds[o]verridefromwriteBackFileItem's refusal message — recommending it would route a user out of a clean refusal into this wedge. When this issue is fixed, that suggestion can be restored (there is a test asserting the message does not currently offer it, which will need updating in the same commit).Where
internal/render/writer.go:200— the convergence read, the first statement ofWriter.Write:Its own comment notes "The same read drives the dry-run preview: it is what lets
apply --dry-runlabel a converged destination 'synced' rather than 'write'." So both the real apply and the preview go through it.A false doc claim to fix alongside
internal/render/writer.go, the doc comment onisRegularOrAbsent, currently says:The predicate exists and is correct, but
Writer.Write's convergence read does not call it, so the claim aboutapply --dry-runis false. Whatever fixes the hang should make that sentence true rather than merely plausible.Suggested shape
Guard the convergence read with the package's own
isRegularOrAbsent. On a non-regular destination the convergence check should be skipped rather than attempted — falling through to the normal write path, whose atomic temp-then-rename replaces the FIFO with a real file, which is the outcome the user wants fromapply.--dry-runmust report rather than block.internal/render/state_apply.go:310and:353also readop.Pathdirectly; they run after the write, so they are likely reached only once the path is regular again, but they should be checked as part of this.Test note
internal/cli/dest_fifo_e2e_unix_test.goalready carries ready-made rows forapply,apply --dry-runandreconcile --auto-override, currentlyt.Skipped citing this issue. Deleting theskip:string on each is enough to inherit the assertion — they were verified to fail with the correctBLOCKEDdiagnostic (8s per row, both destination shapes) when un-skipped against the current code.Any regression test must be timeout-bounded. This class does not fail, it hangs, so a plain call wedges the suite with no diagnostic.