fix(stage): tolerate a corrupted cache/last_coords.txt at startup - #621
Conversation
get_cached_position() only caught RuntimeError, which neither float() nor tuple unpacking ever raise, so any unparseable cache file (truncated by a crash mid-write, hand-edited, binary garbage) raised ValueError / UnicodeDecodeError out of HighContentScreeningGui.__init__ and the software failed to start until the file was deleted by hand. Treat an unreadable or unparseable cache exactly like a missing one: log a WARNING (with a preview of the offending contents) and return None, so the GUI falls through to its existing "no cached position" path and moves Z to the safety position. Non-finite coordinates (nan/inf) are rejected as well, since cache_position() can never write them. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…nt read Follow-up cleanup to the previous commit, from a review pass over it. cache_position() truncated the cache in place before writing, which is what produces the truncated/partial files the previous commit taught the reader to tolerate. Write to a temp file and os.replace() it into place instead, so an interrupted write leaves the last good position intact. Same pattern already used by control/channel_sequence.py. get_cached_position() now separates "could not read the file" from "could not parse it", so a permission error is no longer reported as corruption and the contents=None sentinel, its conditional preview and the comment explaining UnicodeDecodeError's place in the hierarchy all go away. The read is capped at 4096 bytes so a stray large file at this path cannot be slurped into memory during startup, and the preview slices before stripping rather than after. Also: give cache_position() a real docstring instead of the module-level string statement above it, and scope get_cached_position()'s docstring to what it actually guarantees - it rejects unparseable and non-finite values, but does not range check a well-formed stale position against the axis limits. Tests: parametrize the corruption cases over bytes so the undecodable-file case is one more row rather than a separate weaker test, assert every warning names the cache file, pin the contents preview, and cover the interrupted write. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Improves stage startup resilience by safely handling corrupt cached positions and atomically updating the cache.
Changes:
- Treats unreadable, malformed, and non-finite cached coordinates as missing.
- Uses atomic replacement to preserve the previous cache after failed writes.
- Adds corruption and interrupted-write tests.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 1 comment.
| File | Description |
|---|---|
software/squid/stage/utils.py |
Adds tolerant cache reads and atomic writes. |
software/tests/squid/test_stage.py |
Tests missing/corrupt caches and failed writes. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| contents = f.read(_MAX_CACHE_BYTES) | ||
| except (OSError, UnicodeDecodeError) as e: | ||
| _log.warning( | ||
| f"Cached position file '{cache_path}' could not be read ({e}). " | ||
| "Continuing as if there is no cached position." | ||
| ) | ||
| return None | ||
|
|
||
| try: | ||
| x, y, z = (float(v) for v in contents.strip().split(",")) | ||
| if not all(math.isfinite(v) for v in (x, y, z)): | ||
| raise ValueError("non-finite coordinate") |
There was a problem hiding this comment.
Good catch — confirmed and fixed in 5866bb0.
Reproduced it first: a 4118-byte file consisting of 11.0,22.0,1.0 plus whitespace padding to exactly 4096 chars followed by garbage returned Pos(x_mm=11.0, y_mm=22.0, z_mm=1.0), so the stage would have moved to a position parsed from a prefix of a file we never validated — the exact failure mode this series is meant to prevent.
Now reads _MAX_CACHE_BYTES + 1 and rejects anything longer before parsing, so the cap is an assertion about the format ("a valid cache is one short line") rather than a silent truncation. The oversized file funnels into the same "is corrupted" warning path:
WARNING - Cached position file '...' is corrupted (file is larger than 4096 bytes), contents='11.0,22.0,1.0'. Continuing as if there is no cached position.
Added a test for it (padding derived from the constant so it can't drift), plus a regression guard that a second coordinate line is rejected too — that one already passed, since the extra fields fail the 3-tuple unpack.
… prefix Reading exactly _MAX_CACHE_BYTES silently discarded any suffix, so a file whose first 4096 characters happened to be a valid triple plus whitespace parsed as a good position while the rest of the file was garbage - and the stage then moved there at startup, which is the failure this series set out to prevent. Read one byte past the cap and reject anything longer before parsing, so an oversized file is treated as corrupt rather than as its truncated prefix. A valid cache is one short line, so the cap is now an assertion about the format instead of a silent truncation. Reported by Copilot on Cephla-Lab#621. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ring one "Acquire" reads like it saves the frame somewhere, which Snap does not do - it just shows one frame in the live view. Say "View", keep it to one sentence, and state the reason someone would reach for it instead of Live: less photobleaching. The trigger-mode illumination detail that was in the tooltip is implementation behaviour rather than something a user needs at hover time, so it moves up into the comment above the button instead of being dropped. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
62886c8 to
624f791
Compare
Problem
squid.stage.utils.get_cached_position()only caughtRuntimeError, which neitherfloat()nor tuple unpacking ever raise. So ifcache/last_coords.txtwas corrupted — truncated by a crash mid-write, edited by hand, filled with garbage/binary — aValueError(orUnicodeDecodeError) escaped straight out ofHighContentScreeningGui.__init__and the software would not start until someone deleted the file manually.Fix
Read side — treat an unreadable or unparseable cache file exactly like a missing one: log a WARNING and return
None, so the GUI falls through to its existing "no cached position" path (Z moves to the safety position) and startup continues. "Could not read the file" and "could not parse it" are reported separately, so a permission problem isn't described as corruption. Non-finite values (nan/inf) are rejected too, sincecache_position()validates against the axis limits and can never write them. The read is capped at 4096 bytes so a stray large file at that path can't be pulled into memory during startup.Write side —
cache_position()truncated the cache in place before writing, which is what produces the partial files the read side now has to tolerate. It writes to a temp file andos.replace()s it into place instead, so an interrupted write leaves the last good position intact. This matches the existing pattern incontrol/channel_sequence.py.A corrupt file is intentionally left in place rather than deleted; it is overwritten on the next clean exit, and the repeating warning is the right signal if the machine never exits cleanly.
Scope note
get_cached_position()deliberately does not range-check a well-formed but stale position against the axis limits, and its docstring says so. A stale finite out-of-range X/Y would still cause a move timeout at startup; sharing the validation between the read and write sides needsstage_configthreaded into the reader and is left as a follow-up.Also out of scope, found while reviewing this:
control/_def.py(two readers, at import time),control/widgets.py:get_last_used_saving_path(), and several othercache/readers have the same crash-on-corrupt-file shape with five differentexcepttuples between them. A shared tolerant-read/atomic-write helper would make this structural rather than per-author, but it touches 6+ files and doesn't belong in a targeted fix.Tests
tests/squid/test_stage.py: corruption cases parametrized over bytes — garbage, too few / too many fields, a bad field, empty,nan,inf, and undecodable bytes — each assertingNoneis returned and a warning names the cache file; plus a test pinning the contents preview in the warning, and one covering an interrupted write (the previously-cached position must survive). Both behaviors were confirmed failing before the code changed.Black clean at 120 columns.
End-to-end check
Planted
this is not,a valid\x00positionin the cache and launchedmain_hcs.py --simulation:Startup proceeded normally with no traceback. Round-tripping a position after the atomic-write change leaves no
.tmpdebris behind.🤖 Generated with Claude Code