Skip to content

Commit bb5a2df

Browse files
feat: witness exit_pre via post-exit sidecar
The handler writes the sidecar as Blender dies; the catalog runner now forwards expect_sidecar so CI actually asserts it. 4.5 skips at min_version 5.1. Signed-off-by: TMHSDigital <154358121+TMHSDigital@users.noreply.github.com> Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 1960ace commit bb5a2df

11 files changed

Lines changed: 255 additions & 7 deletions

File tree

.cursor-plugin/plugin.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,7 @@
7474
"examples/degenerate-bevel-weld",
7575
"examples/depsgraph-export",
7676
"examples/driver-wave",
77+
"examples/exit-pre-sidecar",
7778
"examples/gltf-export-roundtrip",
7879
"examples/gltf-skin-roundtrip",
7980
"examples/gn-bundle-roundtrip",

AGENTS.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ a `.cursor-plugin/plugin.json` manifest so the ecosystem drift checker
2020
classifies it as a `cursor-plugin`. This is content the AI loads when the user
2121
asks Blender questions or works on Blender add-ons in Cursor or Claude Code.
2222

23-
The content base is 13 skills, 6 rules, 2 templates, 17 snippets, and 49
23+
The content base is 13 skills, 6 rules, 2 templates, 17 snippets, and 50
2424
examples (counts are CI-enforced against README.md and the manifest). The full
2525
inventory tables and per-item purposes live in `CLAUDE.md`. Example anatomy
2626
and authoring rules: copy `examples/bmesh-gear/`; the render look is specified
@@ -35,7 +35,7 @@ Blender-Developer-Tools/
3535
rules/<rule-name>.mdc # 6 rule files
3636
templates/<template-name>/ # 2 starter templates
3737
snippets/<snippet-name>.py # 17 standalone Python snippets
38-
examples/<name>/ # 49 runnable smoke-gated examples (+ gallery.json)
38+
examples/<name>/ # 50 runnable smoke-gated examples (+ gallery.json)
3939
examples/gallery_framing.py # shared Layer 1 framing measurement (render path only)
4040
scripts/build_gallery.py # generates docs/gallery/ (stdlib only)
4141
scripts/site/ # vendored landing-page build (build_site.py + template)

CLAUDE.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ skills/<skill-name>/SKILL.md - AI workflow definitions, 13 total
2121
rules/<rule-name>.mdc - Anti-pattern rules, 6 total
2222
templates/<template-name>/ - Starter projects, 2 total
2323
snippets/<snippet-name>.py - Standalone code patterns, 17 total
24-
examples/<name>/ - Runnable smoke-gated examples, 49 total (+ gallery.json)
24+
examples/<name>/ - Runnable smoke-gated examples, 50 total (+ gallery.json)
2525
scripts/build_gallery.py - Regenerates docs/gallery/ from gallery.json (stdlib only)
2626
scripts/site/ - Vendored landing-page build (Jinja2)
2727
docs/gallery/ - Committed generated gallery pages + hero renders
@@ -83,7 +83,7 @@ v0.1.0: canonical object creation and deletion, depsgraph evaluated mesh, bmesh
8383

8484
v0.2.0: Principled BSDF material, driver-with-custom-function via `driver_namespace`, application handler registration, shader node group with cross-version `interface` API, `foreach_get` bulk vertex read, version-branch skeleton, and USD export with `evaluation_mode='RENDER'`.
8585

86-
## Examples (49)
86+
## Examples (50)
8787

8888
Runnable scripts at `examples/<name>/`, each asserting a real API contract with
8989
deterministic checks (exit non-zero on failure) and optionally rendering a still via

README.md

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818
</p>
1919

2020
<p align="center">
21-
<strong>13 skills</strong> &nbsp;&bull;&nbsp; <strong>6 rules</strong> &nbsp;&bull;&nbsp; <strong>2 templates</strong> &nbsp;&bull;&nbsp; <strong>17 snippets</strong> &nbsp;&bull;&nbsp; <strong>49 examples</strong>
21+
<strong>13 skills</strong> &nbsp;&bull;&nbsp; <strong>6 rules</strong> &nbsp;&bull;&nbsp; <strong>2 templates</strong> &nbsp;&bull;&nbsp; <strong>17 snippets</strong> &nbsp;&bull;&nbsp; <strong>50 examples</strong>
2222
</p>
2323

2424
<p align="center">
@@ -459,7 +459,7 @@ fails creating the 5.x RNA.
459459
</details>
460460

461461
<details>
462-
<summary><strong>Animation, rigging &amp; constraints</strong> — 5 examples</summary>
462+
<summary><strong>Animation, rigging &amp; constraints</strong> — 6 examples</summary>
463463

464464
<table>
465465
<tr>
@@ -488,6 +488,21 @@ A `driver_namespace` function driving sixteen column heights through SCRIPTED dr
488488
Witnesses the evaluation contract: driven values appear after a view-layer update on the
489489
evaluated copy **and** the flushed-back original, and both must match the closed form.
490490

491+
</td>
492+
</tr>
493+
<tr>
494+
<td width="46%" valign="middle">
495+
check-only, no gallery still — no geometry
496+
</td>
497+
<td valign="middle">
498+
499+
### [exit-pre-sidecar](examples/exit-pre-sidecar/)
500+
501+
`bpy.app.handlers.exit_pre` writes `$BDT_SMOKE_SIDECAR` as Blender dies; the
502+
harness asserts `exit_pre-ok` after the process exits. Skips 4.5
503+
(`min_version` 5.1). `--silent-handler` / `--no-handler` miss the file;
504+
`--wrong-text` / `--write-in-main` fail the content check.
505+
491506
</td>
492507
</tr>
493508
<tr>

ROADMAP.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,8 @@ Not committed; target list for the next content version. (v0.3.0 shipped the smo
9696
- ~~Repeat Zone / For Each Element pairing witness~~ **SHIPPED** as `examples/gn-zone-iterate/``pair_with_output` is load-bearing (unpaired evaluates 0 verts); Repeat `8×(1+N)` with X-centers at `k×STEP`; For Each `8×P` with Z-centers at `i×STEP`; For Each main Geometry is a passthrough (6 verts); count-only is insufficient (`--no-offset` still 32 verts, one X-center)
9797
- ~~Geometry Nodes bundle round-trip~~ **SHIPPED** as `examples/gn-bundle-roundtrip/` — 5.x RNA `NodeCombineBundle` (`GeometryNodeCombineBundle` undefined on 5.2); closed form 8/6 + x `[0.5, 2.5]` + `bundle_mark=0.314159`; 4.5 SKIP via catalog `min_version` 5.0 (experimental flag off evaluates empty; `--force-run` hits 5.x RNA undefined)
9898
- Tighten inverted smoke canaries: assert the expected `[FAIL]` marker text (`skipped on ... should run` / `missing post-exit sidecar`) alongside wrapper exit 1, so a canary that dies for the wrong reason does not satisfy the gate
99+
- Falsification flags on shipped examples (Phases 6–8 `--skip-delete` / `--unpair-*` / `--bypass`, Phase 9 `--silent-handler` / `--wrong-text`) were proven local-Windows only — chat and `.scratch`, not CI history. Decide later whether a cron should exercise them or whether flags in the scripts are sufficient record.
100+
- ~~exit_pre sidecar witness~~ **SHIPPED** as `examples/exit-pre-sidecar/` — 5.1+ `handlers.exit_pre` writes `$BDT_SMOKE_SIDECAR`; harness `--expect-sidecar` + `sidecar_contains=exit_pre-ok` after process exit; 4.5 SKIP `min_version` 5.1; `--force-run` is AttributeError; `--silent-handler` / `--no-handler` missing sidecar; `--wrong-text` / `--write-in-main` / `--atexit-instead` wrong contents
99101
- ~~Fleet Pages facelift + examples support~~ **RESOLVED differently (2026-07-03)**: the meta-repo migration was dropped — the fleet template only scaffolds new tools, and each tool's site evolves independently after that. This repo vendored the site build into `scripts/site/`, redesigned landing + gallery as the Blender-viewport system (see `docs/gallery/DESIGN_NOTES.md`), added the examples grid, nav link, and full hero stats locally. `scripts/build_gallery.py` and `examples/gallery.json` are now permanent, not a prototype awaiting lift-and-shift.
100102
- `modal-operators` skill -- `invoke` returning `RUNNING_MODAL`, the `modal()` event handler, modal cancellation patterns
101103
- `usd-pipelines` skill -- USD export options, `evaluation_mode`, instancing, the USD vs glTF tradeoffs
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
# exit_pre sidecar
2+
3+
`bpy.app.handlers.exit_pre` writes `$BDT_SMOKE_SIDECAR` as Blender dies.
4+
Witnesses [`drivers-and-app-handlers`](../../skills/drivers-and-app-handlers/SKILL.md).
5+
`main` does not write the file. The host runner asserts it after the
6+
process exits (`tests/smoke/run_example.py --expect-sidecar`).
7+
8+
5.1+. 4.5 LTS: `AttributeError` — no `exit_pre`. Skip
9+
(`SMOKE_SKIP: exit_pre requires Blender 5.1+`, exit 77, catalog
10+
`min_version` 5.1). `--force-run` bypasses the skip so 4.5 fails
11+
accessing the handler list.
12+
13+
No gallery still. There is no geometry.
14+
15+
**What failure each check would catch:**
16+
17+
- exit 77 — Blender &lt; 5.1 and not `--force-run`
18+
- exit 2 — `--force-run` on 4.5 (`exit_pre` missing)
19+
- harness FAIL missing sidecar — `--silent-handler` / `--no-handler`
20+
- harness FAIL wrong contents — `--wrong-text` (`nope`), `--write-in-main`
21+
(`from-main`), `--atexit-instead` (`atexit-ok`)
22+
23+
The harness checks **contents** (`sidecar_contains=exit_pre-ok`), not
24+
existence only. A file written from `main` or `atexit` is red.
25+
26+
## Run
27+
28+
Via the harness (sets `$BDT_SMOKE_SIDECAR`):
29+
30+
```bash
31+
python tests/smoke/run_example.py --name exit-pre-sidecar \
32+
--blender blender --script examples/exit-pre-sidecar/exit_pre_sidecar.py \
33+
--series 5.2 --min-version 5.1 \
34+
--expect-sidecar /tmp/exit-pre.sidecar --sidecar-contains exit_pre-ok
35+
```
Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
"""exit_pre post-exit sidecar — a runnable example.
2+
3+
Witnesses ``bpy.app.handlers.exit_pre`` from
4+
``skills/drivers-and-app-handlers``. The callback fires as Blender dies.
5+
This script does **not** write ``$BDT_SMOKE_SIDECAR`` in ``main``; the
6+
handler writes it. The host runner (``tests/smoke/run_example.py``)
7+
asserts the file after the process exits.
8+
9+
5.1+. 4.5 LTS has no ``exit_pre`` (AttributeError). Catalog
10+
``min_version`` 5.0 is wrong — the floor is 5.1. Skip: ``SMOKE_SKIP``.
11+
``--force-run`` bypasses the skip so 4.5 fails accessing ``exit_pre``.
12+
13+
No gallery still. There is no geometry.
14+
15+
blender --background --python exit_pre_sidecar.py --
16+
"""
17+
import argparse
18+
import atexit
19+
import os
20+
import sys
21+
22+
import bpy
23+
from bpy.app.handlers import persistent
24+
25+
SKIP_REASON = "exit_pre requires Blender 5.1+"
26+
MARKER = "exit_pre-ok"
27+
MAIN_MARKER = "from-main"
28+
WRONG_MARKER = "nope"
29+
ATEXIT_MARKER = "atexit-ok"
30+
31+
32+
def sidecar_path():
33+
path = os.environ.get("BDT_SMOKE_SIDECAR")
34+
if not path:
35+
print("ERROR: BDT_SMOKE_SIDECAR unset", file=sys.stderr)
36+
return None
37+
return path
38+
39+
40+
def write_sidecar(text):
41+
path = sidecar_path()
42+
if not path:
43+
return False
44+
parent = os.path.dirname(path)
45+
if parent:
46+
os.makedirs(parent, exist_ok=True)
47+
with open(path, "w", encoding="utf-8") as fh:
48+
fh.write(text)
49+
if not text.endswith("\n"):
50+
fh.write("\n")
51+
return True
52+
53+
54+
@persistent
55+
def on_exit_write(*args):
56+
write_sidecar(MARKER)
57+
58+
59+
@persistent
60+
def on_exit_silent(*args):
61+
return
62+
63+
64+
@persistent
65+
def on_exit_wrong(*args):
66+
write_sidecar(WRONG_MARKER)
67+
68+
69+
def on_atexit():
70+
write_sidecar(ATEXIT_MARKER)
71+
72+
73+
def maybe_skip(force_run):
74+
if bpy.app.version >= (5, 1, 0):
75+
return 0
76+
if force_run:
77+
return 0
78+
print(f"SMOKE_SKIP: {SKIP_REASON}", flush=True)
79+
return 77
80+
81+
82+
def main():
83+
argv = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else []
84+
p = argparse.ArgumentParser()
85+
p.add_argument(
86+
"--force-run",
87+
action="store_true",
88+
help="bypass the 5.1 skip; 4.5 fails accessing handlers.exit_pre",
89+
)
90+
p.add_argument(
91+
"--silent-handler",
92+
action="store_true",
93+
help="falsification: register exit_pre that writes nothing",
94+
)
95+
p.add_argument(
96+
"--wrong-text",
97+
action="store_true",
98+
help="falsification: handler writes nope, not exit_pre-ok",
99+
)
100+
p.add_argument(
101+
"--no-handler",
102+
action="store_true",
103+
help="falsification: do not register exit_pre, do not write in main",
104+
)
105+
p.add_argument(
106+
"--write-in-main",
107+
action="store_true",
108+
help="falsification: write from-main in main without exit_pre",
109+
)
110+
p.add_argument(
111+
"--atexit-instead",
112+
action="store_true",
113+
help="falsification: atexit writes atexit-ok, not exit_pre",
114+
)
115+
args = p.parse_args(argv)
116+
117+
skipped = maybe_skip(args.force_run)
118+
if skipped:
119+
return skipped
120+
121+
if args.force_run and bpy.app.version < (5, 1, 0):
122+
try:
123+
bpy.app.handlers.exit_pre.append(on_exit_write)
124+
except AttributeError as exc:
125+
print(f"ERROR: {type(exc).__name__}: {exc}", file=sys.stderr)
126+
return 2
127+
print("ERROR: exit_pre existed on this Blender; 4.5 should AttributeError", file=sys.stderr)
128+
return 2
129+
130+
if sidecar_path() is None:
131+
return 1
132+
133+
if args.write_in_main:
134+
write_sidecar(MAIN_MARKER)
135+
print("wrote sidecar from main (no exit_pre)", flush=True)
136+
return 0
137+
138+
if args.no_handler:
139+
print("no exit_pre registered", flush=True)
140+
return 0
141+
142+
if args.atexit_instead:
143+
atexit.register(on_atexit)
144+
print("registered atexit, not exit_pre", flush=True)
145+
return 0
146+
147+
if args.silent_handler:
148+
bpy.app.handlers.exit_pre.append(on_exit_silent)
149+
print("registered silent exit_pre", flush=True)
150+
return 0
151+
152+
if args.wrong_text:
153+
bpy.app.handlers.exit_pre.append(on_exit_wrong)
154+
print("registered exit_pre writing nope", flush=True)
155+
return 0
156+
157+
bpy.app.handlers.exit_pre.append(on_exit_write)
158+
print("registered exit_pre", flush=True)
159+
return 0
160+
161+
162+
if __name__ == "__main__":
163+
try:
164+
sys.exit(main())
165+
except Exception as exc:
166+
print(f"ERROR: {type(exc).__name__}: {exc}", file=sys.stderr)
167+
sys.exit(1)

skills/drivers-and-app-handlers/SKILL.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -265,6 +265,7 @@ The `exit_pre` handler list is new in Blender 5.1. On 4.5 LTS, fall back to OS-l
265265
- **Recursively modifying the scene from a depsgraph handler.** The modification triggers another depsgraph evaluation, which calls the handler, which modifies the scene. Infinite loop, often manifesting as a hang.
266266
- **Asymmetric register/unregister.** The handler is appended on register but not removed on unregister. Disabling the add-on leaves the callback in place. After enable/disable cycles, the callback runs N times per event.
267267
- **Treating the `save_pre` argument as a Scene.** The save/load handlers receive the **file path string** (empty for the startup file), not a Scene. Name the parameter `filepath` (or take `*args`), and reach scenes via `bpy.context.scene` / `bpy.data.scenes`. A membership test like `'key' in arg0` against the path string is silently wrong, and `del arg0['key']` raises `TypeError`.
268+
- **Doing exit cleanup in the script body instead of `exit_pre`.** A `--background --python` script can `sys.exit(0)` without the handler firing if you never registered it. The witness is a sidecar written from `exit_pre`, asserted after the process dies.
268269

269270
## Version correctness
270271

@@ -279,6 +280,7 @@ The `exit_pre` handler list is new in Blender 5.1. On 4.5 LTS, fall back to OS-l
279280

280281
- Snippet `driver-with-custom-function.py` for the driver_namespace pattern.
281282
- Snippet `app-handler-registration.py` for save_pre with proper unregister.
283+
- Example `exit-pre-sidecar` for `exit_pre` writing a post-exit sidecar (5.1+; skip 4.5).
282284
- Skill `custom-properties` for the data the driver might be reading.
283285

284286
## References

tests/smoke/catalog.json

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,5 +57,12 @@
5757
{"name": "cross-version-property-delete", "script": "examples/cross-version-property-delete/cross_version_property_delete.py"},
5858
{"name": "usd-export-evaluation-mode", "script": "examples/usd-export-evaluation-mode/usd_export_evaluation_mode.py"},
5959
{"name": "gn-zone-iterate", "script": "examples/gn-zone-iterate/gn_zone_iterate.py"},
60-
{"name": "gn-bundle-roundtrip", "script": "examples/gn-bundle-roundtrip/gn_bundle_roundtrip.py", "min_version": "5.0"}
60+
{"name": "gn-bundle-roundtrip", "script": "examples/gn-bundle-roundtrip/gn_bundle_roundtrip.py", "min_version": "5.0"},
61+
{
62+
"name": "exit-pre-sidecar",
63+
"script": "examples/exit-pre-sidecar/exit_pre_sidecar.py",
64+
"min_version": "5.1",
65+
"expect_sidecar": "$OUT/exit-pre-sidecar.sidecar",
66+
"sidecar_contains": "exit_pre-ok"
67+
}
6168
]

tests/smoke/run_catalog.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,13 @@ def main(argv=None):
5353
cmd.append("--xvfb")
5454
if item.get("min_version"):
5555
cmd.extend(["--min-version", item["min_version"]])
56+
if item.get("expect_sidecar"):
57+
cmd.extend([
58+
"--expect-sidecar",
59+
item["expect_sidecar"].replace("$OUT", args.out),
60+
])
61+
if item.get("sidecar_contains"):
62+
cmd.extend(["--sidecar-contains", item["sidecar_contains"]])
5663
if extra:
5764
cmd.append("--")
5865
cmd.extend(extra)

0 commit comments

Comments
 (0)