Skip to content

TASK-053: a frame that raises now fails the run - #93

Merged
AdamClemens merged 2 commits into
mainfrom
feat/task-053-failed-frame-fails-the-run
Sep 13, 2026
Merged

AdamClemens merged 2 commits into
mainfrom
feat/task-053-failed-frame-fails-the-run

Conversation

@AdamClemens

Copy link
Copy Markdown
Owner

DivergenceDidNotConvergeError raised inside RenderWindow._draw's own on_frame callback was swallowed by rendercanvas's with log_exception("Draw error") block — which logs and continues by design, in its own words "otherwise we crash". PyFlow never saw it.

Measured on the shipped cavity refined to 64×64 with its own numerics.timestep left alone:

Draw error: pressure correction loop did not reach tolerance 1e-06 wi… (22)
INFO pyflow.bootstrap: pyflow exited cleanly
EXIT=0

22 of 40 frames failed and the exit code was 0. On glfw the same raise also skipped on_draw's reschedule, so a --max-frames run never reached its budget and hung — observed past 300 s, now exiting 1 in 22 s.

This falsified the second of the two Stage 4 use cases Stage 9 exists for:

Solve a linear system, and be told when it did not converge instead of receiving a plausible wrong answer.

The fix

_draw catches, records and closes; run re-raises from both branches once the event loop has let go. rendercanvas exposes no error-handler API to opt out of its catch — checked directly, not assumed: no set_*error*, error_handler, excepthook or on_error anywhere in the package, and log_exception de-duplicates by message hash so repeated failures degrade to one-liners. The seam has to be on PyFlow's side of the boundary.

__main__.py needed no change — an exception out of main() already gives exit 1 with the real traceback, which is how record/resume have always propagated.

The first attempt was wrong, and that is the part worth reading

Moving frame_count += 1 to after on_frame — so a frame that died would not count as drawn — broke the HUD. It reads frame_count during on_frame, so every run's step readout came out one low, and a three-frame run displayed step 2 t = 20 ms.

Found by an existing test failing, not by reasoning about it. The increment is back where it was, with a rollback in the except branch, which keeps both properties.

Mutation-verified, both halves

mutation tests failed
remove the re-raise 6
remove the frame_count rollback 1

Both reverted before anything was called done.

Honest about the limits

  • play is covered structurally, not end to end. It renders pre-materialized frames, so the engine's own divergence cannot arise inside its frame callback at all. What can — an error in its own scene rebuilding — goes through the same RenderWindow the three new unit tests exercise directly. Stated in the criterion's discharge rather than claimed as end-to-end coverage.
  • The glfw half is display-guarded, so it runs on Windows CI and skips on Linux — the asymmetry docs/planning/backlog.md already carries an open item for, with both attempted fixes and why each was reverted.
  • Dates: drafted 2026-09-12, built 2026-09-13, and the roadmap says both rather than flattening them. That distinction is exactly what make check-dates exists for.

Verification

make ci green: 1214 passed, 20 skipped, 156 scenarios, all 15 structural checks. By hand against the real CLI: a diverging run exits 1 with the engine's own diagnostic and zero occurrences of "exited cleanly"; a healthy run still exits 0 and still says so.

Stage 9 stays opened — TASK-054 (the timestep stability warning) is drafted and not yet built.

🤖 Generated with Claude Code

AdamClemens and others added 2 commits September 13, 2026 00:09
`DivergenceDidNotConvergeError` raised inside `RenderWindow._draw`'s own
`on_frame` callback was swallowed by `rendercanvas`'s
`log_exception("Draw error")` block, which logs and continues by design.
PyFlow never saw it. Measured on the shipped cavity refined to 64x64 with
its own timestep left alone: 22 of 40 frames failed, and the CLI printed
`pyflow exited cleanly` and returned 0.

On glfw the same raise also skipped `on_draw`'s reschedule, so a
--max-frames run never reached its budget and hung -- observed past 300s,
now exiting 1 in 22s.

This falsified the second of the two Stage 4 use cases Stage 9 exists
for: "solve a linear system, and be told when it did not converge instead
of receiving a plausible wrong answer".

`_draw` now catches, records and closes; `run` re-raises from both
branches once the event loop has let go. `rendercanvas` offers no
error-handler API to opt out of its catch -- checked directly, not
assumed -- so the seam has to be on this side of the boundary.
`__main__.py` needed no change: an exception out of `main()` already
gives exit 1 with the real traceback, which is how record/resume have
always propagated.

One thing worth reading, because the first attempt was wrong. Moving
`frame_count += 1` to after `on_frame` -- so a frame that died would not
count as drawn -- broke the HUD: it reads `frame_count` during
`on_frame`, so every run's step readout came out one low, and a
three-frame run displayed `step 2  t = 20 ms`. Found by an existing test
failing, not by reasoning. The increment is back where it was, with a
rollback in the except branch, which keeps both properties. Both halves
are mutation-verified: removing the re-raise fails 6 tests, removing the
rollback fails 1.

Dates: drafted 2026-09-12, built 2026-09-13, and the roadmap says both
rather than flattening them to one. That distinction is what
`make check-dates` exists for.

`make ci` green: 1214 passed, 156 scenarios, all 15 structural checks.
Verified by hand against the real CLI: a diverging run exits 1 with the
engine's diagnostic and no "exited cleanly"; a healthy run still exits 0
and still says so.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`make ci` was green locally and failed on both CI platforms. The
difference was the clock: this task was committed at 23:09 UTC on the
12th, on a machine an hour ahead of UTC where the local time read 00:09
on the 13th. I dated the work by the local clock. `check_dates` resolves
"today" with `date.today()` on whichever machine runs it -- UTC on CI --
so a date that was real locally was a future date there.

The gate was right and I was not. Dates reverted to the 12th, which is
what the commit carries in UTC.

**The property this exposed is recorded in `check_dates.py`'s own
docstring**, because nothing had it written down: "today" is
runner-local, so a contributor ahead of UTC committing late in the
evening can write a date that passes locally and fails CI. The rule that
follows -- date a change by its UTC commit time, not the wall clock in
front of you -- is stated there with how to get it.

Deliberately *not* fixed by making the checker use UTC internally. That
would let a contributor behind UTC write a date the gate accepts and a
reader elsewhere reads as tomorrow, which trades one inconsistency for a
quieter one. One clock, and it is the one CI uses.

The explanatory note added to the roadmap originally contained the
literal ISO date it was explaining, and the checker flagged that too --
fixed the way that docstring already prescribes, by writing it as prose
("the 13th") rather than in the `YYYY-MM-DD` form this repository
reserves for things that have happened. The rule catching its own
documentation is the rule working.

Verified against CI's own clock rather than trusting the local one:
`find_future_dates(..., today=date(2026, 9, 12))` reports clean.

`make ci` green: 1214 passed, 156 scenarios, all 15 structural checks.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@AdamClemens
AdamClemens merged commit b59991a into main Sep 13, 2026
2 checks passed
@AdamClemens
AdamClemens deleted the feat/task-053-failed-frame-fails-the-run branch September 13, 2026 00:05
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.

1 participant