Skip to content

fix(stage): tolerate a corrupted cache/last_coords.txt at startup - #621

Merged
Alpaca233 merged 4 commits into
Cephla-Lab:masterfrom
Alpaca233:fix/tolerate-corrupted-last-coords-cache
Aug 25, 2026
Merged

fix(stage): tolerate a corrupted cache/last_coords.txt at startup#621
Alpaca233 merged 4 commits into
Cephla-Lab:masterfrom
Alpaca233:fix/tolerate-corrupted-last-coords-cache

Conversation

@Alpaca233

@Alpaca233 Alpaca233 commented Aug 25, 2026

Copy link
Copy Markdown
Collaborator

Problem

squid.stage.utils.get_cached_position() only caught RuntimeError, which neither float() nor tuple unpacking ever raise. So if cache/last_coords.txt was corrupted — truncated by a crash mid-write, edited by hand, filled with garbage/binary — a ValueError (or UnicodeDecodeError) escaped straight out of HighContentScreeningGui.__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, since cache_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 sidecache_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 and os.replace()s it into place instead, so an interrupted write leaves the last good position intact. This matches the existing pattern in control/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 needs stage_config threaded 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 other cache/ readers have the same crash-on-corrupt-file shape with five different except tuples 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 asserting None is 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.

python3 -m pytest tests/squid/test_stage.py tests/squid/test_stage_z_limit.py
48 passed, 1 skipped

Black clean at 120 columns.

End-to-end check

Planted this is not,a valid\x00position in the cache and launched main_hcs.py --simulation:

WARNING - Cached position file 'cache/last_coords.txt' is corrupted (could not convert string to float: 'this is not'), contents='this is not,a valid\x00position'. Continuing as if there is no cached position.
INFO - Cache position is not exists.  Moving Z axis to safety position (gui_hcs.py:788)

Startup proceeded normally with no traceback. Round-tripping a position after the atomic-write change leaves no .tmp debris behind.

🤖 Generated with Claude Code

Alpaca233 and others added 2 commits August 25, 2026 10:49
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>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread software/squid/stage/utils.py Outdated
Comment on lines +32 to +43
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")

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Alpaca233 and others added 2 commits August 25, 2026 13:13
… 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>
@Alpaca233
Alpaca233 force-pushed the fix/tolerate-corrupted-last-coords-cache branch from 62886c8 to 624f791 Compare August 25, 2026 21:24
@Alpaca233
Alpaca233 merged commit 0f789a4 into Cephla-Lab:master Aug 25, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants