TASK-053: a frame that raises now fails the run - #93
Merged
Merged
Conversation
`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>
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.
DivergenceDidNotConvergeErrorraised insideRenderWindow._draw's ownon_framecallback was swallowed byrendercanvas'swith 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.timestepleft alone:22 of 40 frames failed and the exit code was 0. On
glfwthe same raise also skippedon_draw's reschedule, so a--max-framesrun 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:
The fix
_drawcatches, records and closes;runre-raises from both branches once the event loop has let go.rendercanvasexposes no error-handler API to opt out of its catch — checked directly, not assumed: noset_*error*,error_handler,excepthookoron_erroranywhere in the package, andlog_exceptionde-duplicates by message hash so repeated failures degrade to one-liners. The seam has to be on PyFlow's side of the boundary.__main__.pyneeded no change — an exception out ofmain()already gives exit 1 with the real traceback, which is howrecord/resumehave always propagated.The first attempt was wrong, and that is the part worth reading
Moving
frame_count += 1to afteron_frame— so a frame that died would not count as drawn — broke the HUD. It readsframe_countduringon_frame, so every run's step readout came out one low, and a three-frame run displayedstep 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
exceptbranch, which keeps both properties.Mutation-verified, both halves
frame_countrollbackBoth reverted before anything was called done.
Honest about the limits
playis 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 sameRenderWindowthe three new unit tests exercise directly. Stated in the criterion's discharge rather than claimed as end-to-end coverage.docs/planning/backlog.mdalready carries an open item for, with both attempted fixes and why each was reverted.make check-datesexists for.Verification
make cigreen: 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