Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -149,9 +149,11 @@ and the lid-driven cavity's own error against Ghia, Ghia & Shin (1982)
*fell at every resolution* (9x9 0.1433 -> 0.1292, 13x13 0.0874 ->
0.0766, 17x17 0.0578 -> 0.0524), which is independent evidence the
change was physics rather than a re-fitted tolerance. Its own demo is
`uv run python -m pyflow run --demos sealed_box`. TASK-053 (a failed
frame failing the run) and TASK-054 (the timestep stability warning)
are drafted and not yet built. It is placed before Better Numerics by dependency,
`uv run python -m pyflow run --demos sealed_box`. **TASK-053 has landed too**: a run whose frames raise now exits non-zero
with the engine's own diagnostic instead of printing `pyflow exited
cleanly` and returning 0, and a `--max-frames` interactive run that
blows up terminates in 22 s rather than hanging past 300. TASK-054 (the
timestep stability warning) is drafted and not yet built. It is placed before Better Numerics by dependency,
not preference -- Stage 10's own Rayleigh-Bénard criterion measures
convection between heated walls, which is not meaningful while those
walls leak.
Expand Down
63 changes: 56 additions & 7 deletions docs/planning/roadmap.md
Original file line number Diff line number Diff line change
Expand Up @@ -381,10 +381,12 @@ This paragraph previously said `make install` and `make test` were still
expected to fail, pending `uv.lock` and a test suite (B2/C1) -- stale
since 2026-08-16 and corrected 2026-08-19. Both now succeed: `uv.lock`
is committed (B2) and `make test` runs the suite with coverage
(C1a/C1b): **1226 tests as of 2026-09-12**, up from 1209 on 2026-09-11
(C1a/C1b): **1234 tests as of 2026-09-12**, up from 1209 on 2026-09-11
(TASK-052, Stage 9: 9 in `tests/unit/test_boundary_velocity.py` and 3 in
`tests/golden/test_sealed_box.py` for the wall-permeability fix and its
own golden demo, plus 5 the fixtures those changed gained along the way).
own golden demo, plus 5 the fixtures those changed gained along the way;
then TASK-053: 5 in `tests/integration/test_frame_failure.py` and 3 in
`tests/unit/test_rendering.py` for a frame that raises failing the run).
Before that, 1209 on 2026-09-11
(the Stage 8 exit audit, closing the gap between three Completion
Criteria and what actually checked them: 4 in
Expand Down Expand Up @@ -13015,8 +13017,17 @@ own status table.

## TASK-053 — A Failed Frame Fails The Run

**Status: Not started, drafted 2026-09-12.** Will discharge Completion
Criterion 4.
**Status: Done, 2026-09-12.** Discharges Completion Criterion 4.

**The date was briefly recorded as the 13th, and CI was right to
reject it.** This task was committed at 23:09 UTC on the 12th, on a
machine an hour ahead of UTC, where the local clock read 00:09 on the
13th. `check_dates` resolves "today" against `date.today()` on
whichever machine runs it, so a date that was real locally was a
*future* date on CI's own UTC runners and `make ci` failed on both
platforms while passing locally. See
`tools/validators/check_dates.py`'s own docstring for the property
this exposed, which nothing had recorded.

### Purpose

Expand Down Expand Up @@ -13062,6 +13073,18 @@ killed.
already gives exit 1 with the real traceback -- exactly how
`record`/`resume` propagate today.

### Artifacts Produced

- `src/pyflow/rendering/window.py` -- `_draw` catches, records and closes;
`_raise_any_frame_error` re-raises from both of `run`'s branches;
`frame_count` moved to after a successful `on_frame`; the offscreen
loop breaks rather than running its budget out over failing frames.
- `src/pyflow/bootstrap.py` -- a comment at the `window.run(...)` call
site recording that `"pyflow exited cleanly"` is now unreachable on a
failed run, and that the guarantee lives one function away.
- Tests: 4 in `tests/integration/test_frame_failure.py` (3 offscreen, 1
display-guarded glfw), 3 in `tests/unit/test_rendering.py`.

### Acceptance Criteria

Prose bullets rather than a `.feature` file, the same scope judgement
Expand All @@ -13072,10 +13095,36 @@ scope is "real simulation work".
- A run that raises inside a frame exits non-zero and prints the engine's
own diagnostic, asserted as an exit code **and** a stderr substring,
per `tests/integration/`'s own convention -- an exit code alone does
not distinguish a real failure from argparse.
- Covered on both backends and from both `run` and `play`.
- A `--max-frames` `glfw` run that raises terminates rather than hanging.
not distinguish a real failure from argparse. **Met**, against a real
configuration that genuinely diverges rather than a monkeypatched
exception: the 64x64 cavity whose own measurements are in the Purpose
above.
- It does not print `pyflow exited cleanly`, and does not log a
completed frame budget. **Met**, both asserted separately from the
exit code, because the three failed independently.
- A healthy run still exits 0 and still says so. **Met** -- a guard that
always trips is a guard nobody can act on.
- Covered on both backends and from both `run` and `play`. **Met with a
stated limit.** The offscreen path is covered end to end; the glfw
path has its own integration test, **display-guarded, so it runs on
Windows CI and skips on Linux** -- the asymmetry
`docs/planning/backlog.md` already carries an open item for, recorded
here rather than left implicit. `play` is covered structurally rather
than end to end: it renders pre-materialized frames, so the engine's
own divergence cannot arise inside its frame callback at all, and what
can (an error in its own scene rebuilding) goes through the same
`RenderWindow` the three unit tests exercise directly.
- A `--max-frames` `glfw` run that raises terminates rather than
hanging. **Met** -- measured at **22 s to exit 1**, against a
pre-fix run observed still hanging at 300 s.
- Mutation-verified: removing the re-raise fails the new test.
**Confirmed** -- it fails five, across both the unit and integration
levels, and was reverted before anything was called done.

### Discharges

Completion Criterion 4 in full, with the platform limit on its glfw half
stated above rather than glossed.

---

Expand Down
14 changes: 7 additions & 7 deletions docs/planning/status.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,14 +17,14 @@ demand, not part of this file.

## Progress

**53/55 tasks complete (96%)** across 17 planned stages. For the full plan, including
**54/55 tasks complete (98%)** across 17 planned stages. For the full plan, including
stages below not yet broken into tasks: [roadmap.md](roadmap.md).

```mermaid
pie showData
title "Tasks across the roadmap"
"Done" : 53
"Not started" : 2
"Done" : 54
"Not started" : 1
```

### Milestones
Expand All @@ -41,12 +41,12 @@ pie showData

### Up next

**Stage 9 -- Solver & Run Integrity** is next, starting with TASK-053 (A Failed Frame Fails The Run), 1 more not yet started in this stage.
**Stage 9 -- Solver & Run Integrity** is next, starting with TASK-054 (Timestep Stability Warning).

## Live repository facts

- **49** `CLAUDE.md` files
- **1226** tests collected
- **1234** tests collected
- **156** Gherkin scenarios (`tests/features/*.feature`)

## Stages
Expand Down Expand Up @@ -168,12 +168,12 @@ pie showData

### Stage 9 -- Solver & Run Integrity

**no status recorded** -- `███░░░░░░░` 1/3 tasks; 7 criteria defined, no status line yet
**no status recorded** -- `███████░░░` 2/3 tasks; 7 criteria defined, no status line yet

| Task | Status | Date | Artifact |
|------|--------|------|----------|
| TASK-052 -- Prescribed Boundary Velocity Reaches The Schemes | Done | 2026-09-12 | `examples/golden-demos/smoke_transport.yaml` |
| TASK-053 -- A Failed Frame Fails The Run | Not started | | |
| TASK-053 -- A Failed Frame Fails The Run | Done | 2026-09-12 | `tools/validators/check_dates.py` |
| TASK-054 -- Timestep Stability Warning | Not started | | |

### Stage 10 -- Better Numerics
Expand Down
3 changes: 2 additions & 1 deletion docs/repository-inventory.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ reading job and lives in the manifest. Test counts and coverage are
not here either -- those come from running the suite, not from
listing files.

**368 tracked files** across 49 directories;
**369 tracked files** across 49 directories;
2 are empty.

## (root)
Expand Down Expand Up @@ -403,6 +403,7 @@ listing files.
- `test_claude_hooks.py`
- `test_cli.py`
- `test_fluid_configuration.py`
- `test_frame_failure.py`
- `test_import_order.py`
- `test_interactive_window.py`
- `test_playback_cli.py`
Expand Down
7 changes: 6 additions & 1 deletion docs/repository-manifest.md
Original file line number Diff line number Diff line change
Expand Up @@ -1011,7 +1011,12 @@ window with a genuine injected Space key proving the rendered pixels
stop changing once paused -- the same `_needs_a_real_display` pattern
`test_interactive_window.py` established, its own display-probe helper
copied locally rather than imported since `tests/` is not an importable
package here). The repository-tooling tests
package here), and `test_frame_failure.py` (TASK-053, 2026-09-12: a
real configuration that genuinely diverges, run through the CLI, proving
a run whose frames raise exits non-zero with the engine's own diagnostic
rather than printing `pyflow exited cleanly` and returning 0 -- plus a
display-guarded glfw case for the hang that same defect caused).
The repository-tooling tests
live in `unit/` alongside them: `test_check_docs.py`,
`test_check_claims.py`, `test_check_graph.py` and
`test_generate_docs_index.py`/`test_generate_dependency_tree.py`/
Expand Down
8 changes: 8 additions & 0 deletions src/pyflow/bootstrap.py
Original file line number Diff line number Diff line change
Expand Up @@ -1175,6 +1175,14 @@ def _on_frame() -> None:

window.apply_camera_config()

# `run` re-raises anything the frame callback raised (TASK-053,
# Stage 9, 2026-09-12), so this line is unreachable on a failed run and the
# exception propagates out of `main()` to a non-zero exit. It used to
# be reached unconditionally: `rendercanvas` swallowed the
# exception, `run` returned normally, and a run whose every frame had
# failed logged that it exited cleanly and returned 0. Stated here
# rather than left implicit, because nothing about this call site
# shows that the guarantee lives one function away.
window.run(max_frames=max_frames, on_frame=on_frame)
logger.info("pyflow exited cleanly")
return window
27 changes: 27 additions & 0 deletions src/pyflow/rendering/CLAUDE.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,32 @@
# CLAUDE

**`RenderWindow._draw` catches whatever `on_frame` raises, records it,
and closes the canvas; `run` re-raises it afterward (TASK-053, Stage 9,
2026-09-12).** The exception must not be allowed to escape `_draw`,
because `_draw` is installed as `rendercanvas`'s own `_draw_frame` and
that library calls it inside `with log_exception("Draw error")` --
which logs and continues by design, in its own words "otherwise we
crash". Before this task a diverging simulation's
`DivergenceDidNotConvergeError` went into that block and nowhere else:
`pyflow run` printed `pyflow exited cleanly` and returned **0** after 22
of 40 frames had failed. On `glfw` the same raise also skipped
`on_draw`'s own reschedule, so a `--max-frames` run never reached its
budget and **hung** -- observed past 300 s, now exiting 1 in 22 s.

**`rendercanvas` offers no way to opt out of that catch, checked rather
than assumed**: no `set_*error*`, `error_handler`, `excepthook` or
`on_error` anywhere in the package, and `log_exception` de-duplicates by
message hash, so a repeating failure degrades to one-liners. The seam
that works is PyFlow's own, on this side of the boundary. **Any new
callback this window invokes on behalf of a caller needs the same
treatment** -- an exception that reaches `rendercanvas` is an exception
nobody sees.

**`frame_count` is incremented only after `on_frame` returns**, for the
same reason: it used to be incremented before, so a frame that died in
the simulation still counted as drawn, and a failing run could report a
full frame budget.

Rendering subsystem: window/render-loop bootstrap (`docs/planning/roadmap.md`
TASK-007) and visualisation of scalar/vector fields.

Expand Down
73 changes: 72 additions & 1 deletion src/pyflow/rendering/window.py
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,11 @@ def __init__(self, config: RenderingConfig) -> None:
would otherwise be a local closure variable nothing outside
`play()` could see."""
self._on_frame: Callable[[], None] | None = None
self._frame_error: Exception | None = None
"""Whatever `on_frame` last raised, if anything (TASK-053).
`run` re-raises it; `_draw` cannot, because the caller above it
is `rendercanvas`, which swallows.
"""
self._pan_drag_start_screen: tuple[float, float] | None = None
self._pan_drag_start_position: tuple[float, float, float] | None = None

Expand Down Expand Up @@ -251,10 +256,68 @@ def _end_pan(self) -> None:
self._pan_drag_start_position = None

def _draw(self) -> None:
"""One frame: render, then advance whatever `run`'s own `on_frame`
callback advances.

**Anything `on_frame` raises is caught here, recorded, and the
window closed (TASK-053, Stage 9, 2026-09-12) -- not allowed to escape
`rendercanvas`.** This method is installed as that library's own
`_draw_frame`, and it calls it inside `with
log_exception("Draw error")`, which logs and continues by design
("otherwise we crash", in its own comment). So an exception that
escapes here is not propagated to anybody: before this task,
`DivergenceDidNotConvergeError` from a diverging simulation was
swallowed exactly that way, and `pyflow run` printed `pyflow
exited cleanly` and returned 0 after 22 of 40 frames had failed.

`rendercanvas` exposes no error-handler API to opt out of that --
checked directly, not assumed: there is no `set_*error*`,
`error_handler`, `excepthook` or `on_error` anywhere in the
package, and `log_exception` de-duplicates by message hash, so a
repeating failure degrades to one-liners. The seam that works is
PyFlow's own: catch before the boundary, stash, and let `run`
re-raise once the loop is over.

**`frame_count` is incremented before `on_frame` and rolled back
if it raises**, rather than simply incremented afterward. The
distinction is not cosmetic: the HUD's own per-frame update is
composed into `on_frame` (`bootstrap.py`), and it reads
`frame_count` to render "step N / elapsed t" -- so incrementing
afterward makes the frame that is *currently being drawn* report
the previous frame's number, and every run's step readout comes
out one low. Found by `test_bootstrap_stats_use_configured_time_
units_for_elapsed_time` failing on a three-frame run that
displayed `step 2 t = 20 ms`, not by reasoning about it.

The rollback keeps the property that motivated the change: a
frame whose simulation step died does not count as drawn, so a
failing run cannot report a full frame budget.
"""
self.renderer.render(self.scene, self.camera)
self.frame_count += 1
if self._on_frame is not None:
self._on_frame()
try:
self._on_frame()
except Exception as error: # noqa: BLE001 -- re-raised by `run`
self._frame_error = error
self.frame_count -= 1
self.canvas.close()

def _raise_any_frame_error(self) -> None:
"""Re-raise whatever `_draw` caught, now that the event loop has
let go (TASK-053, Stage 9, 2026-09-12).

Called on both of `run`'s branches, because both need it for
different reasons: the offscreen loop is PyFlow's own `for` and
would otherwise return a full frame budget of failures, and the
interactive one hands control to `get_loop(...).run()`, which
returns only once the canvas closes -- which `_draw` does on the
failing frame.
"""
error = self._frame_error
if error is not None:
self._frame_error = None
raise error

def run(
self,
Expand Down Expand Up @@ -308,7 +371,14 @@ def run(
self.canvas.request_draw(self._draw)
for _ in range(max_frames or 1):
self.last_image = self.canvas.draw()
# `canvas.draw()` returns normally even when `_draw`
# failed, so the budget has to be abandoned explicitly --
# unlike the interactive branch below, where closing the
# canvas ends the loop on its own.
if self._frame_error is not None:
break
self.canvas.close()
self._raise_any_frame_error()
logger.info("offscreen render complete: %d frame(s)", self.frame_count)
return

Expand Down Expand Up @@ -360,4 +430,5 @@ def on_draw() -> None:
)
self.canvas.request_draw(on_draw)
get_loop(self._config).run()
self._raise_any_frame_error()
logger.info("render window closed: %d frame(s)", self.frame_count)
16 changes: 16 additions & 0 deletions tests/integration/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,22 @@ of windows rather than redistribute them -- the two
is the highest count here. See `docs/planning/backlog.md` for the open
item.

**`test_frame_failure.py` (TASK-053, Stage 9, added 2026-09-12) crosses
the boundary for a third distinct reason: an exit code that only exists
outside the process.** Its claim is that a run whose frames raise fails
-- and the observable is the exit code, which an in-process call to
`RenderWindow.run` cannot produce. `tests/unit/test_rendering.py` covers
the re-raise itself; this covers what a user meets.

**Its fixture is a real configuration that genuinely diverges, not a
monkeypatched exception** -- the shipped cavity refined to 64x64 with
its own timestep left alone, measured at 2.05x the stability limit and
blowing up at step 17. A patched exception would prove the plumbing
carries *an* error; this proves the engine's own diagnostic reaches a
user, which is the Stage 4 use case the criterion exists for. Its glfw
half is display-guarded and so runs on Windows only, the same asymmetry
this file already records above.

**Comparing rendered pixels: never build the reference from the run
under test.** `test_playback_cli.py`'s two `*_rerenders_the_field_in_
real_pixels` tests are the worked example, and their module comments
Expand Down
Loading
Loading