From 87e04abfec212518565bcc074b4c63d05cf7f7ba Mon Sep 17 00:00:00 2001
From: TMHSDigital <154358121+TMHSDigital@users.noreply.github.com>
Date: Wed, 9 Sep 2026 21:56:22 -0400
Subject: [PATCH] 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
- 13 skills • 6 rules • 2 templates • 17 snippets • 49 examples + 13 skills • 6 rules • 2 templates • 17 snippets • 50 examples
@@ -459,7 +459,7 @@ fails creating the 5.x RNA.
Animation, rigging & constraints — 5 examples
+Animation, rigging & constraints — 6 examples
@@ -488,6 +488,21 @@ A `driver_namespace` function driving sixteen column heights through SCRIPTED dr
Witnesses the evaluation contract: driven values appear after a view-layer update on the
evaluated copy **and** the flushed-back original, and both must match the closed form.
+
+
+
+
+check-only, no gallery still — no geometry
+
+
+
+### [exit-pre-sidecar](examples/exit-pre-sidecar/)
+
+`bpy.app.handlers.exit_pre` writes `$BDT_SMOKE_SIDECAR` as Blender dies; the
+harness asserts `exit_pre-ok` after the process exits. Skips 4.5
+(`min_version` 5.1). `--silent-handler` / `--no-handler` miss the file;
+`--wrong-text` / `--write-in-main` fail the content check.
+
diff --git a/ROADMAP.md b/ROADMAP.md
index ec84b9a..f42d7ea 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -96,6 +96,8 @@ Not committed; target list for the next content version. (v0.3.0 shipped the smo
- ~~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)
- ~~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)
- 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
+- 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.
+- ~~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
- ~~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.
- `modal-operators` skill -- `invoke` returning `RUNNING_MODAL`, the `modal()` event handler, modal cancellation patterns
- `usd-pipelines` skill -- USD export options, `evaluation_mode`, instancing, the USD vs glTF tradeoffs
diff --git a/examples/exit-pre-sidecar/README.md b/examples/exit-pre-sidecar/README.md
new file mode 100644
index 0000000..2286044
--- /dev/null
+++ b/examples/exit-pre-sidecar/README.md
@@ -0,0 +1,35 @@
+# exit_pre sidecar
+
+`bpy.app.handlers.exit_pre` writes `$BDT_SMOKE_SIDECAR` as Blender dies.
+Witnesses [`drivers-and-app-handlers`](../../skills/drivers-and-app-handlers/SKILL.md).
+`main` does not write the file. The host runner asserts it after the
+process exits (`tests/smoke/run_example.py --expect-sidecar`).
+
+5.1+. 4.5 LTS: `AttributeError` — no `exit_pre`. Skip
+(`SMOKE_SKIP: exit_pre requires Blender 5.1+`, exit 77, catalog
+`min_version` 5.1). `--force-run` bypasses the skip so 4.5 fails
+accessing the handler list.
+
+No gallery still. There is no geometry.
+
+**What failure each check would catch:**
+
+- exit 77 — Blender < 5.1 and not `--force-run`
+- exit 2 — `--force-run` on 4.5 (`exit_pre` missing)
+- harness FAIL missing sidecar — `--silent-handler` / `--no-handler`
+- harness FAIL wrong contents — `--wrong-text` (`nope`), `--write-in-main`
+ (`from-main`), `--atexit-instead` (`atexit-ok`)
+
+The harness checks **contents** (`sidecar_contains=exit_pre-ok`), not
+existence only. A file written from `main` or `atexit` is red.
+
+## Run
+
+Via the harness (sets `$BDT_SMOKE_SIDECAR`):
+
+```bash
+python tests/smoke/run_example.py --name exit-pre-sidecar \
+ --blender blender --script examples/exit-pre-sidecar/exit_pre_sidecar.py \
+ --series 5.2 --min-version 5.1 \
+ --expect-sidecar /tmp/exit-pre.sidecar --sidecar-contains exit_pre-ok
+```
diff --git a/examples/exit-pre-sidecar/exit_pre_sidecar.py b/examples/exit-pre-sidecar/exit_pre_sidecar.py
new file mode 100644
index 0000000..3fbfac0
--- /dev/null
+++ b/examples/exit-pre-sidecar/exit_pre_sidecar.py
@@ -0,0 +1,167 @@
+"""exit_pre post-exit sidecar — a runnable example.
+
+Witnesses ``bpy.app.handlers.exit_pre`` from
+``skills/drivers-and-app-handlers``. The callback fires as Blender dies.
+This script does **not** write ``$BDT_SMOKE_SIDECAR`` in ``main``; the
+handler writes it. The host runner (``tests/smoke/run_example.py``)
+asserts the file after the process exits.
+
+5.1+. 4.5 LTS has no ``exit_pre`` (AttributeError). Catalog
+``min_version`` 5.0 is wrong — the floor is 5.1. Skip: ``SMOKE_SKIP``.
+``--force-run`` bypasses the skip so 4.5 fails accessing ``exit_pre``.
+
+No gallery still. There is no geometry.
+
+ blender --background --python exit_pre_sidecar.py --
+"""
+import argparse
+import atexit
+import os
+import sys
+
+import bpy
+from bpy.app.handlers import persistent
+
+SKIP_REASON = "exit_pre requires Blender 5.1+"
+MARKER = "exit_pre-ok"
+MAIN_MARKER = "from-main"
+WRONG_MARKER = "nope"
+ATEXIT_MARKER = "atexit-ok"
+
+
+def sidecar_path():
+ path = os.environ.get("BDT_SMOKE_SIDECAR")
+ if not path:
+ print("ERROR: BDT_SMOKE_SIDECAR unset", file=sys.stderr)
+ return None
+ return path
+
+
+def write_sidecar(text):
+ path = sidecar_path()
+ if not path:
+ return False
+ parent = os.path.dirname(path)
+ if parent:
+ os.makedirs(parent, exist_ok=True)
+ with open(path, "w", encoding="utf-8") as fh:
+ fh.write(text)
+ if not text.endswith("\n"):
+ fh.write("\n")
+ return True
+
+
+@persistent
+def on_exit_write(*args):
+ write_sidecar(MARKER)
+
+
+@persistent
+def on_exit_silent(*args):
+ return
+
+
+@persistent
+def on_exit_wrong(*args):
+ write_sidecar(WRONG_MARKER)
+
+
+def on_atexit():
+ write_sidecar(ATEXIT_MARKER)
+
+
+def maybe_skip(force_run):
+ if bpy.app.version >= (5, 1, 0):
+ return 0
+ if force_run:
+ return 0
+ print(f"SMOKE_SKIP: {SKIP_REASON}", flush=True)
+ return 77
+
+
+def main():
+ argv = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else []
+ p = argparse.ArgumentParser()
+ p.add_argument(
+ "--force-run",
+ action="store_true",
+ help="bypass the 5.1 skip; 4.5 fails accessing handlers.exit_pre",
+ )
+ p.add_argument(
+ "--silent-handler",
+ action="store_true",
+ help="falsification: register exit_pre that writes nothing",
+ )
+ p.add_argument(
+ "--wrong-text",
+ action="store_true",
+ help="falsification: handler writes nope, not exit_pre-ok",
+ )
+ p.add_argument(
+ "--no-handler",
+ action="store_true",
+ help="falsification: do not register exit_pre, do not write in main",
+ )
+ p.add_argument(
+ "--write-in-main",
+ action="store_true",
+ help="falsification: write from-main in main without exit_pre",
+ )
+ p.add_argument(
+ "--atexit-instead",
+ action="store_true",
+ help="falsification: atexit writes atexit-ok, not exit_pre",
+ )
+ args = p.parse_args(argv)
+
+ skipped = maybe_skip(args.force_run)
+ if skipped:
+ return skipped
+
+ if args.force_run and bpy.app.version < (5, 1, 0):
+ try:
+ bpy.app.handlers.exit_pre.append(on_exit_write)
+ except AttributeError as exc:
+ print(f"ERROR: {type(exc).__name__}: {exc}", file=sys.stderr)
+ return 2
+ print("ERROR: exit_pre existed on this Blender; 4.5 should AttributeError", file=sys.stderr)
+ return 2
+
+ if sidecar_path() is None:
+ return 1
+
+ if args.write_in_main:
+ write_sidecar(MAIN_MARKER)
+ print("wrote sidecar from main (no exit_pre)", flush=True)
+ return 0
+
+ if args.no_handler:
+ print("no exit_pre registered", flush=True)
+ return 0
+
+ if args.atexit_instead:
+ atexit.register(on_atexit)
+ print("registered atexit, not exit_pre", flush=True)
+ return 0
+
+ if args.silent_handler:
+ bpy.app.handlers.exit_pre.append(on_exit_silent)
+ print("registered silent exit_pre", flush=True)
+ return 0
+
+ if args.wrong_text:
+ bpy.app.handlers.exit_pre.append(on_exit_wrong)
+ print("registered exit_pre writing nope", flush=True)
+ return 0
+
+ bpy.app.handlers.exit_pre.append(on_exit_write)
+ print("registered exit_pre", flush=True)
+ return 0
+
+
+if __name__ == "__main__":
+ try:
+ sys.exit(main())
+ except Exception as exc:
+ print(f"ERROR: {type(exc).__name__}: {exc}", file=sys.stderr)
+ sys.exit(1)
diff --git a/skills/drivers-and-app-handlers/SKILL.md b/skills/drivers-and-app-handlers/SKILL.md
index 4912085..11022ed 100644
--- a/skills/drivers-and-app-handlers/SKILL.md
+++ b/skills/drivers-and-app-handlers/SKILL.md
@@ -265,6 +265,7 @@ The `exit_pre` handler list is new in Blender 5.1. On 4.5 LTS, fall back to OS-l
- **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.
- **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.
- **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`.
+- **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.
## Version correctness
@@ -279,6 +280,7 @@ The `exit_pre` handler list is new in Blender 5.1. On 4.5 LTS, fall back to OS-l
- Snippet `driver-with-custom-function.py` for the driver_namespace pattern.
- Snippet `app-handler-registration.py` for save_pre with proper unregister.
+- Example `exit-pre-sidecar` for `exit_pre` writing a post-exit sidecar (5.1+; skip 4.5).
- Skill `custom-properties` for the data the driver might be reading.
## References
diff --git a/tests/smoke/catalog.json b/tests/smoke/catalog.json
index 4902bef..a11d421 100644
--- a/tests/smoke/catalog.json
+++ b/tests/smoke/catalog.json
@@ -57,5 +57,12 @@
{"name": "cross-version-property-delete", "script": "examples/cross-version-property-delete/cross_version_property_delete.py"},
{"name": "usd-export-evaluation-mode", "script": "examples/usd-export-evaluation-mode/usd_export_evaluation_mode.py"},
{"name": "gn-zone-iterate", "script": "examples/gn-zone-iterate/gn_zone_iterate.py"},
- {"name": "gn-bundle-roundtrip", "script": "examples/gn-bundle-roundtrip/gn_bundle_roundtrip.py", "min_version": "5.0"}
+ {"name": "gn-bundle-roundtrip", "script": "examples/gn-bundle-roundtrip/gn_bundle_roundtrip.py", "min_version": "5.0"},
+ {
+ "name": "exit-pre-sidecar",
+ "script": "examples/exit-pre-sidecar/exit_pre_sidecar.py",
+ "min_version": "5.1",
+ "expect_sidecar": "$OUT/exit-pre-sidecar.sidecar",
+ "sidecar_contains": "exit_pre-ok"
+ }
]
diff --git a/tests/smoke/run_catalog.py b/tests/smoke/run_catalog.py
index 9b0d1a8..ef7ee46 100644
--- a/tests/smoke/run_catalog.py
+++ b/tests/smoke/run_catalog.py
@@ -53,6 +53,13 @@ def main(argv=None):
cmd.append("--xvfb")
if item.get("min_version"):
cmd.extend(["--min-version", item["min_version"]])
+ if item.get("expect_sidecar"):
+ cmd.extend([
+ "--expect-sidecar",
+ item["expect_sidecar"].replace("$OUT", args.out),
+ ])
+ if item.get("sidecar_contains"):
+ cmd.extend(["--sidecar-contains", item["sidecar_contains"]])
if extra:
cmd.append("--")
cmd.extend(extra)
diff --git a/tests/smoke/test_harness.py b/tests/smoke/test_harness.py
index 58b8e0e..2cafbb2 100644
--- a/tests/smoke/test_harness.py
+++ b/tests/smoke/test_harness.py
@@ -6,6 +6,7 @@
"""
from __future__ import annotations
+import json
import os
import sys
import tempfile
@@ -159,5 +160,16 @@ def test_empty_is_not_green(self):
self.assertEqual(code, 2)
+class CatalogWiring(unittest.TestCase):
+ def test_exit_pre_row_has_sidecar_and_floor(self):
+ here = os.path.dirname(os.path.abspath(__file__))
+ with open(os.path.join(here, "catalog.json"), encoding="utf-8") as fh:
+ catalog = json.load(fh)
+ row = next(i for i in catalog if i["name"] == "exit-pre-sidecar")
+ self.assertEqual(row["min_version"], "5.1")
+ self.assertIn("$OUT", row["expect_sidecar"])
+ self.assertEqual(row["sidecar_contains"], "exit_pre-ok")
+
+
if __name__ == "__main__":
unittest.main()