fix: stop a crashed run poisoning every later run of the same search - #1480
Merged
Conversation
A run interrupted while writing its output left a half-written JSON file on
disk, and every subsequent run of that search name then died on it -- from
inside an OPTIONAL sanity check -- until the output directory was deleted by
hand.
Reproduced against main, using the real trigger (a float32 killing save_json
at the end of a successful fit):
run 1: TypeError: Object of type float32 is not JSON serializable
run 2: JSONDecodeError: Expecting value: line 1 column 13 (char 12)
The message names no file and suggests no remedy, and run 2 is not a one-off:
nothing rewrites the corrupt file until a run gets far enough to finish, so
the search is wedged.
Three causes, fixed as three legs.
1. Writes were not atomic. `open(path, "w+")` truncates first and writes
second, so a failure partway destroys the file that was there. Adds
`open_atomic`, which writes a sibling temp file and `os.replace`s it into
place -- atomic on POSIX and Windows, and same-directory so it stays a
rename within one filesystem. On failure the temp file is removed and the
original is untouched. It catches `BaseException`, since a KeyboardInterrupt
mid-write leaves the same debris. Used by `save_json` and by
`save_search_internal`, which matters more: search_internal is what a
resumed run restores its step count and counters from.
2. `Fitness.check_log_likelihood` aborted the run on an unreadable summary.
It already returns early on `FileNotFoundError` -- no previous run, nothing
to check -- and a CORRUPT summary is the same situation, since there is no
trustworthy old likelihood either way. It now returns early there too, with
a warning naming the cause and the remedy rather than silence, because an
unreadable file is a real event unlike a missing one.
3. The multi-start resume guard had the same narrow shape. It caught
`(FileNotFoundError, TypeError, KeyError)`, so a corrupt `search_internal`
raised instead of falling into the fresh-start branch directly below.
Widened to also treat truncated/unpicklable state as "no state to resume
from", warning when it does.
The reason all three missed it is one fact: `json.JSONDecodeError` subclasses
`ValueError`, so it is neither a `FileNotFoundError`, a `TypeError` nor a
`KeyError`, and fell through every guard on the resume path. Asserted directly
in a test so it cannot silently stop being true.
After: the same sequence warns and completes a full 30-step run instead of
dying.
9 new tests. Full suite: 1800 passed, 4 skipped, 1 failed -- the failure
(`test_nautilus.py::test__single_core_builds_no_pool`) is pre-existing and
reproduces identically on a clean tree.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FzF2XmKQaqZRWZfZMxvTNR
Resolves the one conflict, in `DirectoryPaths.save_json`, by keeping BOTH fixes rather than either: #1479's `cls=NumpyEncoder` and this branch's `open_atomic`. They answer different halves of the same incident -- the encoder stops the float32 write failing, the atomic write stops ANY failed write destroying the previous file -- so taking one alone would reintroduce the other's bug. Full suite on the merged result: 1819 passed, 4 skipped, 1 failed (the pre-existing nautilus single-core pool test). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FzF2XmKQaqZRWZfZMxvTNR
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
A run interrupted while writing its output left a half-written JSON file on disk, and every subsequent run of that search name then died on it — from inside an optional sanity check — until the output directory was deleted by hand.
Reproduced against
mainUsing the real trigger, a
float32killingsave_jsonat the end of a successful fit:The message names no file and suggests no remedy. And run 2 is not a one-off: nothing rewrites the corrupt file until a run gets far enough to finish, so the search stays wedged.
Note on the original report. This was filed as producing "a 4-second no-op run that reads as a clean result". That is not what reproduces here — what reproduces is the hard crash above. The silent-no-op variant presumably needs a surviving
search_internal(whosetotal_stepsshort-circuits the loop). Both are the same root cause, and the fix covers both paths, but I could only reproduce the crash and would rather say so than claim otherwise.Three causes, three legs
1. Writes were not atomic.
open(path, "w+")truncates first and writes second, so a failure partway destroys the file that was there. Addsopen_atomic, which writes a sibling temp file andos.replaces it into place — atomic on POSIX and Windows, and same-directory so it stays a rename within one filesystem. On failure the temp file is removed and the original is untouched. It catchesBaseException, since aKeyboardInterruptmid-write leaves exactly the same debris.Used by
save_jsonand bysave_search_internal— the latter matters more, sincesearch_internalis what a resumed run restores its step count and counters from.2.
Fitness.check_log_likelihoodaborted the run on an unreadable summary. It already returns early onFileNotFoundError— no previous run, nothing to check — and a corrupt summary is the same situation, since there is no trustworthy old likelihood either way. It now returns early there too, with a warning naming the cause and the remedy rather than silence: an unreadable file is a real event, unlike a missing one.3. The multi-start resume guard had the same narrow shape. It caught
(FileNotFoundError, TypeError, KeyError), so a corruptsearch_internalraised instead of falling into the fresh-start branch directly below it. Widened to treat truncated or unpicklable state as "no state to resume from", warning when it does.The one fact behind all three
json.JSONDecodeErrorsubclassesValueError. So it is neither aFileNotFoundError, aTypeErrornor aKeyError, and it fell through every guard on the resume path. Asserted directly in a test so it cannot silently stop being true.After
The same sequence warns and completes a full 30-step run instead of dying:
Testing
9 new tests:
open_atomicsuccess/failure/interrupt/temp-file-cleanup/binary,save_jsonpreserving the previous file when a write fails, theJSONDecodeError-is-a-ValueErrorassertion, and an end-to-end two-run test that asserts the second run takes the resume path (rather than being short-circuited as already-complete, which would make it assert nothing) and does not raise.Full suite: 1800 passed, 4 skipped, 1 failed. The failure (
test_nautilus.py::test__single_core_builds_no_pool) is pre-existing and reproduces identically on a clean tree.Related
Branched from
main, independent of #1478 and #1479. #1479 removes the most common trigger (thefloat32crash); this PR removes the consequence, so a crash from any cause — full disk, killed process, interrupt — can no longer wedge a search. They are worth landing together but neither depends on the other.🤖 Generated with Claude Code
https://claude.ai/code/session_01FzF2XmKQaqZRWZfZMxvTNR
Generated by Claude Code