From e1b21390950a13964ff20f947f9421075f08529f Mon Sep 17 00:00:00 2001 From: TMHSDigital <154358121+TMHSDigital@users.noreply.github.com> Date: Wed, 7 Oct 2026 10:14:09 -0400 Subject: [PATCH] fix(examples): handle N jobs and job errors in thread-queue-timer; make three checks falsifiable The thread-queue-timer pattern stopped its drain after the first result, leaving a second job's result stranded, and a raising job left the timer polling forever. The snippet and skill now count pending jobs, drain everything per tick, and have the worker put the exception. Three examples carried checks that passed by construction: the image-pixels orientation check compared two Python lists, the extension --validate-only falsifier reached exit 4 by tautology, and the timers main-thread and still-registered checks could never fire. Each now fails only through the Blender behavior it claims, with a catalog falsifier per check. Closes #463 Closes #470 Signed-off-by: TMHSDigital Co-Authored-By: Claude Opus 5.5 --- docs/gallery/image-pixels-testcard/index.html | 8 +- .../extension-package-lifecycle/README.md | 19 +- .../extension_package_lifecycle.py | 30 +-- examples/image-pixels-testcard/README.md | 22 ++- .../image_pixels_testcard.py | 100 +++++++++- examples/index.json | 59 +++++- examples/timers-modal-threading/README.md | 99 +++++++--- .../timers_modal_threading.py | 185 ++++++++++++++---- .../extension-runtime-and-packaging/SKILL.md | 2 +- skills/headless-batch-scripting/SKILL.md | 2 +- skills/operators/SKILL.md | 2 +- skills/timers-modal-and-threading/SKILL.md | 46 +++-- snippets/thread-queue-timer.py | 31 ++- tests/smoke/catalog.json | 6 +- 14 files changed, 469 insertions(+), 142 deletions(-) diff --git a/docs/gallery/image-pixels-testcard/index.html b/docs/gallery/image-pixels-testcard/index.html index f5ae25ce..d1c5bea3 100644 --- a/docs/gallery/image-pixels-testcard/index.html +++ b/docs/gallery/image-pixels-testcard/index.html @@ -65,14 +65,14 @@

Image Pixels Testcard

What it witnesses: the bpy.types.Image pixel-buffer contract. Image.pixels is a flat, row-major, bottom-left-origin float buffer that is always RGBA: channels == 4 and len(pixels) == width × height × 4 even when the image is created with alpha=False, so an RGB-stride foreach_set raises TypeError instead of writing. A byte image (the default) stores 8 bits per channel — every written float round-trips with error ≤ 0.5/255 and strictly > 0 (an exact round-trip would mean storage is not 8-bit); float_buffer=True stores float32 and round-trips at ~1e-7. Image.scale() reallocates the buffer, so a foreach_get into a stale-size list raises TypeError rather than silently shearing rows.

The save() trap (found while authoring — identical on 4.5 LTS and 5.1): Image.save() on a GENERATED image silently flips source to 'FILE' and drops the in-memory buffer (has_data becomes False). Every later pixels read re-loads from whatever currently sits at filepath_raw. The check proves it by overwriting the file with a flat-gray imposter image after save() and reading the imposter's pixels back through the original datablock. save_render() writes the same PNG but is non-destructive: source stays 'GENERATED' and the buffer stays exact. Scripts that write pixels, save(), then keep computing on pixels are silently computing on a decoded PNG.

What each check catches on failure:

- +

Version divergence: none — every contract above, including the save() trap, was probed and asserts identically on Blender 4.5.11 LTS and 5.1.2. The only gate in the file is the EEVEE engine id for the optional render (BLENDER_EEVEE_NEXT on 4.x, BLENDER_EEVEE on 5.x).

Render hazard worth knowing: a bmesh-built plane has no UV map, and bmesh.ops.create_grid(..., calc_uvs=True) silently creates none unless a UV layer already exists — without one, an Image Texture samples texel (0,0) for every fragment and the screen renders as one flat color. The render path creates the layer explicitly.

Run#

# Cheap correctness check (no render) — the CI check:
 blender --background --python image_pixels_testcard.py --
 
-# Falsifier: write the card top-down. Must exit non-zero (byte round-trip).
+# Falsifier: write the card top-down. Must exit 13 (PNG row order).
 blender --background --python image_pixels_testcard.py -- --wrong-origin
 
 # Also render a still (EEVEE on a GPU host; use --engine cycles on GPU-less hosts):
@@ -80,8 +80,8 @@ 

Run blender --background --python image_pixels_testcard.py -- --output card.png --engine cycles

Exit codes#

Per-script sequential checks. 9 is a valid check code; there is no rule against it.

-
CodeMeaning
0Success
1Uncaught exception (FATAL wrapper)
2argparse / usage
3Pixel buffer is not always RGBA
4Byte round-trip vs closed-form card (--wrong-origin lands here)
5Float-buffer round-trip failed
6scale() did not reallocate, or stale-size read succeeded
7save() source/buffer-drop contract drifted
8save_render() flipped source or disturbed the buffer
9Byte PNG save/reload error
10Framing gate violation on the --output path (gallery_framing)
12--output produced no file
-

The blender-smoke workflow runs the check on Blender 5.2 LTS and 4.5 LTS (5.1 on the weekly cron, the needs-5.1 PR label, or manual dispatch). Smoke does not pass --output. Its catalog falsifier is --wrong-origin (expects exit 4).

+
CodeMeaning
0Success
1Uncaught exception (FATAL wrapper)
2argparse / usage
3Pixel buffer is not always RGBA
4Byte round-trip error or no quantization
5Float-buffer round-trip failed
6scale() did not reallocate, or stale-size read succeeded
7save() source/buffer-drop contract drifted
8save_render() flipped source or disturbed the buffer
9Byte PNG save/reload error
10Framing gate violation on the --output path (gallery_framing)
12--output produced no file
13Saved PNG rows do not put pixel (0, 0) at the bottom-left (--wrong-origin lands here)
+

The blender-smoke workflow runs the check on Blender 5.2 LTS and 4.5 LTS (5.1 on the weekly cron, the needs-5.1 PR label, or manual dispatch). Smoke does not pass --output. Its catalog falsifier is --wrong-origin (expects exit 13).

In the render, Closest interpolation keeps the pixel grid honest — the jagged circle edge is the 512 × 288 buffer itself, and the white marker in the PLUGE row sits at the bottom-left because that is where pixel (0, 0) lives. The monitor is staged as a broadcast reference display — slim beveled bezel over a stepped rear housing, a matte sun hood framing the picture, input keys and a teal power LED on the bottom bezel, a machined stand — on a walnut desk with drawer pedestals, on the dark studio stage from docs/VISUAL-STYLE.md (Standard view transform; warm key, cool fill and rim; a warm pool raking the back wall). The screen stays emissive and matte — specular off, emission strength 1.0 — so the card's values read exactly. The render path gates framing through examples/gallery_framing.py (exit 10) before writing; the desk is passed as stage, like the floor, because its crop at the frame bottom is the composition.

diff --git a/examples/extension-package-lifecycle/README.md b/examples/extension-package-lifecycle/README.md index 9bda8ed1..306d05ff 100644 --- a/examples/extension-package-lifecycle/README.md +++ b/examples/extension-package-lifecycle/README.md @@ -36,10 +36,12 @@ Scaffolding matches [`eval-mesh-datablock-name`](../eval-mesh-datablock-name/) **What failure each check would catch:** - exit 3 — the template stopped validating or building, or the zip lost its manifest -- exit 4 — a package whose wheel does not exist passed the release gate - (`--validate-only` lands here: it gates on `validate` alone); also exit 4 if - `validate` starts rejecting the missing wheel, because the trap this example - names would then be gone +- exit 4 — `build` accepted the package whose manifest names a wheel it does + not ship, so nothing between `validate` and a release would catch it + (`--ship-wheel` lands here: it creates the named wheel, so `build` exits 0 + and writes a zip, proving the rejection comes from the missing file); also + exit 4 if `validate` starts rejecting the package, because the trap this + example names would then be gone - exit 5 — `server-generate` wrote no listing, or the listing does not name the package - exit 6 — install, import, upgrade or remove failed, or `__package__` is not `bl_ext.user_default.example_addon` @@ -60,7 +62,8 @@ Scaffolding matches [`eval-mesh-datablock-name`](../eval-mesh-datablock-name/) | user data after `remove` | deleted | deleted | deleted | | `online_access` default / `--online-mode` | False / True | False / True | False / True | | default exit | 0 | 0 | 0 | -| `--validate-only` / `--data-next-to-file` exit | 4 / 7 | 4 / 7 | 4 / 7 | +| `--ship-wheel` / `--data-next-to-file` exit | 4 / 7 | not re-run / 7 | 4 / 7 | +| validate / build, wheel shipped (`--ship-wheel`) | 0 / 0, zip written | not re-run | 0 / 0, zip written | ## API reference @@ -73,7 +76,7 @@ Scaffolding matches [`eval-mesh-datablock-name`](../eval-mesh-datablock-name/) ```bash blender --background --python extension_package_lifecycle.py -- -blender --background --python extension_package_lifecycle.py -- --validate-only +blender --background --python extension_package_lifecycle.py -- --ship-wheel blender --background --python extension_package_lifecycle.py -- --data-next-to-file ``` @@ -88,7 +91,7 @@ than most examples. | 1 | Uncaught exception (FATAL wrapper) | | 2 | argparse / usage | | 3 | Template does not validate or build, or the zip lacks its manifest | -| 4 | Missing-wheel package passed the release gate (`--validate-only` lands here) | +| 4 | `build` accepted the missing-wheel package, or `validate` rejected it (`--ship-wheel` lands here) | | 5 | `server-generate` listing missing or wrong | | 6 | Install, import, upgrade or remove failed, or `__package__` wrong | | 7 | User data inside the install tree, lost on upgrade, or kept after removal (`--data-next-to-file` lands here) | @@ -97,4 +100,4 @@ than most examples. The `blender-smoke` workflow runs the check on Blender 5.2 LTS and 4.5 LTS (5.1 on the weekly cron, the `needs-5.1` PR label, or manual dispatch). Smoke passes no extra flags on the happy path. Its catalog falsifiers are -`--validate-only` (expects exit 4) and `--data-next-to-file` (expects exit 7). +`--ship-wheel` (expects exit 4) and `--data-next-to-file` (expects exit 7). diff --git a/examples/extension-package-lifecycle/extension_package_lifecycle.py b/examples/extension-package-lifecycle/extension_package_lifecycle.py index ca666147..3e80fbc5 100644 --- a/examples/extension-package-lifecycle/extension_package_lifecycle.py +++ b/examples/extension-package-lifecycle/extension_package_lifecycle.py @@ -20,7 +20,7 @@ Check-only: no gallery still. The witnesses are exit codes and paths. blender --background --python extension_package_lifecycle.py -- - blender --background --python extension_package_lifecycle.py -- --validate-only + blender --background --python extension_package_lifecycle.py -- --ship-wheel blender --background --python extension_package_lifecycle.py -- --data-next-to-file """ import argparse @@ -92,24 +92,32 @@ def check_build(work): return 0, dist -def check_wheel_trap(work, validate_only): +def check_wheel_trap(work, ship_wheel): """validate parses the manifest; only build opens the files it names.""" src = copy_template(os.path.join(work, "wheel_src")) manifest = os.path.join(src, "blender_manifest.toml") with open(manifest, "a", encoding="utf-8") as fh: fh.write(f'\nwheels = ["{MISSING_WHEEL}"]\n') + if ship_wheel: # falsification: the named wheel exists, so build has nothing to reject + wheel = os.path.normpath(os.path.join(src, MISSING_WHEEL)) + os.makedirs(os.path.dirname(wheel)) + with zipfile.ZipFile(wheel, "w") as z: + z.writestr("not_shipped/__init__.py", "") + z.writestr("not_shipped-1.0.dist-info/METADATA", + "Metadata-Version: 2.1\nName: not_shipped\nVersion: 1.0\n") out_dir = os.path.join(work, "wheel_dist") os.makedirs(out_dir) v_code, _ = blender(["--command", "extension", "validate", src]) b_code, b_out = blender(["--command", "extension", "build", "--source-dir", src, "--output-dir", out_dir]) shipped = os.listdir(out_dir) - print(f"missing wheel: validate exit {v_code}, build exit {b_code}, output {shipped}") + print(f"{'shipped' if ship_wheel else 'missing'} wheel: validate exit {v_code}, " + f"build exit {b_code}, output {shipped}") if v_code != 0: - return fail("validate now rejects a missing wheel; the trap this example names is gone", 4) - gate_passed = v_code == 0 if validate_only else (b_code == 0 and bool(shipped)) - if gate_passed: - return fail("a package whose wheel does not exist passed the release gate " - f"({'validate only' if validate_only else 'build'})", 4) + return fail("validate rejects the package; the validate-is-not-a-gate trap this " + "example names is gone", 4) + if b_code == 0 or shipped: + return fail(f"build accepted the package (exit {b_code}, output {shipped}); " + "only build can reject a wheel the manifest names but the source lacks", 4) return 0 @@ -222,8 +230,8 @@ def check_online(user_dir): def main(): argv = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else [] p = argparse.ArgumentParser() - p.add_argument("--validate-only", action="store_true", - help="falsification: gate the release on validate alone") + p.add_argument("--ship-wheel", action="store_true", + help="falsification: create the wheel the manifest names, so build succeeds") p.add_argument("--data-next-to-file", action="store_true", help="falsification: store user data beside __file__") args = p.parse_args(argv) @@ -235,7 +243,7 @@ def main(): code, dist = check_build(work) if code: return code - code = check_wheel_trap(work, args.validate_only) + code = check_wheel_trap(work, args.ship_wheel) if code: return code code = check_listing(dist) diff --git a/examples/image-pixels-testcard/README.md b/examples/image-pixels-testcard/README.md index ae721f2c..d5ff744f 100644 --- a/examples/image-pixels-testcard/README.md +++ b/examples/image-pixels-testcard/README.md @@ -29,9 +29,18 @@ decoded PNG. - *Buffer geometry* — an API change to per-image channel counts, or code assuming an RGB stride (falsified: a `W*H*3` write raises and the check exits 3). -- *Byte/float round-trip vs the closed-form card* — any stride, orientation, or - packing bug in the bulk path; a one-pixel shift was deliberately introduced once and - the check exited 4 with measured error 0.97 against tolerance 0.00196. +- *Byte/float round-trip* — a stride or packing bug in the bulk path; a one-pixel + shift was deliberately introduced once and the check exited 4 with measured error + 0.97 against tolerance 0.00196. A `foreach_set` → `foreach_get` round trip reads + back the order it wrote, so it cannot see row order; that is the next check's job. +- *Row order through a real PNG* — the byte image is saved with `Image.save()` and + the file's rows are decoded with `zlib` + `struct` alone, no `bpy`. PNG stores the + top row first, so a bottom-left origin puts pixel (0, 0) — the origin marker — in + the file's **last** row, and every PNG row `k` must match card row `H-1-k` at + quantization tolerance. Measured: the marker sits in PNG rows 247..287 of 0..287 + and the rows match at 0.0019608 (tolerance 0.0019618). `--wrong-origin` writes the + card top-down through Blender: the marker lands in PNG rows 0..40, the row error + is 0.9216, and the check exits 13. - *Quantization floor* — `byte_err > 0` proves 8-bit storage really quantizes; byte and float images swapping behavior cannot hide. - *Reallocation* — `scale()` no longer reallocating (stale-size read succeeding). @@ -57,7 +66,7 @@ and the screen renders as one flat color. The render path creates the layer expl # Cheap correctness check (no render) — the CI check: blender --background --python image_pixels_testcard.py -- -# Falsifier: write the card top-down. Must exit non-zero (byte round-trip). +# Falsifier: write the card top-down. Must exit 13 (PNG row order). blender --background --python image_pixels_testcard.py -- --wrong-origin # Also render a still (EEVEE on a GPU host; use --engine cycles on GPU-less hosts): @@ -76,7 +85,7 @@ against it. | 1 | Uncaught exception (FATAL wrapper) | | 2 | argparse / usage | | 3 | Pixel buffer is not always RGBA | -| 4 | Byte round-trip vs closed-form card (`--wrong-origin` lands here) | +| 4 | Byte round-trip error or no quantization | | 5 | Float-buffer round-trip failed | | 6 | `scale()` did not reallocate, or stale-size read succeeded | | 7 | `save()` source/buffer-drop contract drifted | @@ -84,10 +93,11 @@ against it. | 9 | Byte PNG save/reload error | | 10 | Framing gate violation on the `--output` path (`gallery_framing`) | | 12 | `--output` produced no file | +| 13 | Saved PNG rows do not put pixel (0, 0) at the bottom-left (`--wrong-origin` lands here) | The `blender-smoke` workflow runs the check on Blender 5.2 LTS and 4.5 LTS (5.1 on the weekly cron, the `needs-5.1` PR label, or manual dispatch). -Smoke does not pass `--output`. Its catalog falsifier is `--wrong-origin` (expects exit 4). +Smoke does not pass `--output`. Its catalog falsifier is `--wrong-origin` (expects exit 13). In the render, `Closest` interpolation keeps the pixel grid honest — the jagged circle edge is the 512 × 288 buffer itself, and the white marker in the diff --git a/examples/image-pixels-testcard/image_pixels_testcard.py b/examples/image-pixels-testcard/image_pixels_testcard.py index 2780070a..aa229497 100644 --- a/examples/image-pixels-testcard/image_pixels_testcard.py +++ b/examples/image-pixels-testcard/image_pixels_testcard.py @@ -18,9 +18,14 @@ original datablock. `save_render()` writes the same PNG but leaves `source` == 'GENERATED' and the buffer intact and exact. -``--wrong-origin`` writes the card top-down and still compares against -the bottom-left closed form, so the byte round-trip fails. That is the -falsifier (``--same-axis`` in export-preset-axis). +Row order is witnessed outside Blender: the byte image is saved as a PNG +and its rows are decoded with zlib + struct alone. PNG stores the top row +first, so a bottom-left origin puts pixel (0, 0) — the origin marker — in +the file's LAST row. A foreach_set -> foreach_get round trip cannot see row +order; it reads back the order it wrote. ``--wrong-origin`` writes the card +top-down through Blender, so the saved PNG has the marker on top and the +row check exits 13. That is the falsifier (``--same-axis`` in +export-preset-axis). By default it runs only the correctness check (no render) — the CI smoke check. Pass --output to also render a still: @@ -29,7 +34,7 @@ blender --background --python image_pixels_testcard.py -- --wrong-origin # must fail blender --background --python image_pixels_testcard.py -- --output t.png # + render """ -import bpy, sys, os, math, argparse, tempfile +import bpy, sys, os, math, argparse, tempfile, struct, zlib # Shared Layer 1 framing measurement (render path only) — see # gallery_framing.py for the __file__-relative import shim this relies on. @@ -89,6 +94,59 @@ def flat_pattern(flip_origin=False): return buf +def read_png_rows(path): + """Decode an 8-bit RGB/RGBA PNG with zlib + struct alone — no bpy, so + Blender cannot vouch for itself. Returns (width, height, rows); rows[0] is + the file's first (TOP) row, each row a list of per-pixel float tuples.""" + with open(path, "rb") as fh: + data = fh.read() + if data[:8] != b"\x89PNG\r\n\x1a\n": + raise ValueError(f"{path} is not a PNG") + pos, idat, ihdr = 8, [], None + while pos < len(data): + n, kind = struct.unpack(">I4s", data[pos:pos + 8]) + body = data[pos + 8:pos + 8 + n] + pos += 12 + n + if kind == b"IHDR": + ihdr = struct.unpack(">IIBBBBB", body) + elif kind == b"IDAT": + idat.append(body) + elif kind == b"IEND": + break + w, h, depth, ctype, _, _, interlace = ihdr + if depth != 8 or ctype not in (2, 6) or interlace: + raise ValueError(f"unsupported PNG layout depth={depth} color={ctype} interlace={interlace}") + ch = 3 if ctype == 2 else 4 + raw = zlib.decompress(b"".join(idat)) + stride = w * ch + rows, prev, i = [], bytearray(stride), 0 + for _ in range(h): + f, line = raw[i], bytearray(raw[i + 1:i + 1 + stride]) + i += 1 + stride + for x in range(stride): # undo the per-row filter (PNG spec section 9) + a = line[x - ch] if x >= ch else 0 + b, c = prev[x], (prev[x - ch] if x >= ch else 0) + if f == 1: + line[x] = (line[x] + a) & 255 + elif f == 2: + line[x] = (line[x] + b) & 255 + elif f == 3: + line[x] = (line[x] + ((a + b) >> 1)) & 255 + elif f == 4: + p = a + b - c + pa, pb, pc = abs(p - a), abs(p - b), abs(p - c) + line[x] = (line[x] + (a if pa <= pb and pa <= pc else b if pb <= pc else c)) & 255 + prev = line + rows.append([tuple(v / 255.0 for v in line[x * ch:x * ch + ch]) for x in range(w)]) + return w, h, rows + + +def is_marker_row(row): + """The PLUGE row: bright origin marker at x=0, dark block at x=100 (the + top bar row is bright at x=0 too, but yellow at x=100).""" + return row[0][0] > 0.85 and row[100][0] < 0.2 + + def fail(msg, code): print(f"ERROR: {msg}", file=sys.stderr) return code @@ -111,17 +169,41 @@ def check(wrong_origin=False): pass # -- byte image: one bulk write, quantized round-trip -------------------- + # A foreach_set -> foreach_get round trip is blind to row order (it reads + # back the same flat order it wrote), so it witnesses quantization only. img.pixels.foreach_set(written) got = [0.0] * (W * H * 4) img.pixels.foreach_get(got) - byte_err = max(abs(a - b) for a, b in zip(expected, got)) + byte_err = max(abs(a - b) for a, b in zip(written, got)) if byte_err > BYTE_TOL: - return fail(f"byte round-trip error {byte_err:.7f} > {BYTE_TOL:.7f} " - f"(stride/orientation bug cannot hide at 512x288)", 4) + return fail(f"byte round-trip error {byte_err:.7f} > {BYTE_TOL:.7f}", 4) if byte_err <= 0.0: return fail("byte image round-tripped exactly — storage is not 8-bit, " "the quantization contract is broken", 4) + # -- row order, through Blender's PNG encoder and an independent decoder -- + # PNG stores the TOP row first. If pixels[0] is the bottom-left pixel, the + # file's last row is buffer row 0 and carries the origin marker. + tmpdir = tempfile.mkdtemp() + img.filepath_raw = os.path.join(tmpdir, "orient.png") + img.file_format = 'PNG' + img.save() + pw, ph, rows = read_png_rows(img.filepath_raw) + if (pw, ph) != (W, H): + return fail(f"saved PNG is {pw}x{ph}, expected {W}x{H}", 13) + marker_rows = [k for k, row in enumerate(rows) if is_marker_row(row)] + row_err = 0.0 + for k, row in enumerate(rows): + base = (H - 1 - k) * W * 4 # PNG row k (top first) is buffer row H-1-k + for x in range(W): + for c in range(3): + row_err = max(row_err, abs(row[x][c] - expected[base + x * 4 + c])) + span = f"{marker_rows[0]}..{marker_rows[-1]}" if marker_rows else "none" + if row_err > BYTE_TOL or not is_marker_row(rows[-1]): + return fail(f"PNG rows do not put pixel (0, 0) at the bottom-left: max error " + f"{row_err:.4f} vs the bottom-up card (tol {BYTE_TOL:.5f}), origin " + f"marker in PNG rows {span} of 0..{H - 1}", 13) + # -- float image: same write, float32-exact round-trip ------------------- fimg = bpy.data.images.new("TestCardF", W, H, alpha=True, float_buffer=True) if not fimg.is_float: @@ -147,7 +229,6 @@ def check(wrong_origin=False): pass # -- the save() trap: source flips to FILE and pixels re-source from disk -- - tmpdir = tempfile.mkdtemp() trap = bpy.data.images.new("Trap", W, H, alpha=True, float_buffer=True) trap.pixels.foreach_set(expected) trap.filepath_raw = os.path.join(tmpdir, "trap.png") @@ -198,7 +279,8 @@ def check(wrong_origin=False): return fail(f"byte PNG save/reload error {disk_err:.7f} > {BYTE_TOL:.7f}", 9) print(f"byte round-trip max err {byte_err:.7f} (tol {BYTE_TOL:.7f}, must be > 0), " - f"float {float_err:.2e} (tol {FLOAT_TOL:.0e}), post-save() imposter read " + f"PNG rows vs bottom-up card {row_err:.7f}, origin marker in PNG rows " + f"{span} of 0..{H - 1}, float {float_err:.2e} (tol {FLOAT_TOL:.0e}), post-save() imposter read " f"max dev {trap_err:.7f} (buffer really dropped), save_render() " f"{keep_err:.2e}, byte PNG reload {disk_err:.7f}") return 0 diff --git a/examples/index.json b/examples/index.json index 9758dad8..b6202c62 100644 --- a/examples/index.json +++ b/examples/index.json @@ -705,7 +705,7 @@ "falsifiers": [ { "args": [ - "--validate-only" + "--ship-wheel" ], "expect_exit": 4 }, @@ -721,7 +721,7 @@ "1": "Uncaught exception (FATAL wrapper)", "2": "argparse / usage", "3": "Template does not validate or build, or the zip lacks its manifest", - "4": "Missing-wheel package passed the release gate (`--validate-only` lands here)", + "4": "`build` accepted the missing-wheel package, or `validate` rejected it (`--ship-wheel` lands here)", "5": "`server-generate` listing missing or wrong", "6": "Install, import, upgrade or remove failed, or `__package__` wrong", "7": "User data inside the install tree, lost on upgrade, or kept after removal (`--data-next-to-file` lands here)", @@ -1188,7 +1188,7 @@ "args": [ "--wrong-origin" ], - "expect_exit": 4 + "expect_exit": 13 } ], "exit_codes": { @@ -1196,14 +1196,15 @@ "1": "Uncaught exception (FATAL wrapper)", "2": "argparse / usage", "3": "Pixel buffer is not always RGBA", - "4": "Byte round-trip vs closed-form card (`--wrong-origin` lands here)", + "4": "Byte round-trip error or no quantization", "5": "Float-buffer round-trip failed", "6": "`scale()` did not reallocate, or stale-size read succeeded", "7": "`save()` source/buffer-drop contract drifted", "8": "`save_render()` flipped source or disturbed the buffer", "9": "Byte PNG save/reload error", "10": "Framing gate violation on the `--output` path (`gallery_framing`)", - "12": "`--output` produced no file" + "12": "`--output` produced no file", + "13": "Saved PNG rows do not put pixel (0, 0) at the bottom-left (`--wrong-origin` lands here)" } }, { @@ -1914,29 +1915,67 @@ "run": "blender --background --python examples/timers-modal-threading/timers_modal_threading.py", "summary": "Proves the event-loop contracts behind long-running add-on work.", "falsifiers": [ + { + "args": [ + "--windowed-background-child" + ], + "expect_exit": 3 + }, { "args": [ "--return-zero-once" ], "expect_exit": 5 }, + { + "args": [ + "--return-late-once" + ], + "expect_exit": 5 + }, + { + "args": [ + "--apply-in-worker" + ], + "expect_exit": 6 + }, + { + "args": [ + "--no-event-timer" + ], + "expect_exit": 7 + }, { "args": [ "--non-persistent" ], "expect_exit": 8 + }, + { + "args": [ + "--stop-after-first" + ], + "expect_exit": 9 + }, + { + "args": [ + "--no-catch" + ], + "expect_exit": 12 } ], "exit_codes": { "0": "Success", "1": "Uncaught exception (FATAL wrapper)", "2": "argparse / usage", - "3": "`--background` child failed, or its timer ran", + "3": "`--background` child failed, or its timer ran (`--windowed-background-child` lands here)", "4": "Windowed child wrote no result", - "5": "Timer return values not honoured (`--return-zero-once` lands here)", - "6": "Worker result not applied once on the main thread", - "7": "Modal operator did not finish and remove its timer", - "8": "File load did not keep only the persistent timer (`--non-persistent` lands here)" + "5": "Timer return values not honoured (`--return-zero-once`, `--return-late-once` land here)", + "6": "A worker result or error was handled off the main thread (`--apply-in-worker` lands here)", + "7": "Modal operator did not finish and remove its timer (`--no-event-timer` lands here)", + "8": "File load did not keep only the persistent timer (`--non-persistent` lands here)", + "9": "Not every concurrent job's result was applied (`--stop-after-first` lands here)", + "12": "Raising job not reported, or the drain kept polling (`--no-catch` lands here)" } }, { diff --git a/examples/timers-modal-threading/README.md b/examples/timers-modal-threading/README.md index e6ba7ed2..7d5de95e 100644 --- a/examples/timers-modal-threading/README.md +++ b/examples/timers-modal-threading/README.md @@ -5,7 +5,7 @@ modal operators only run where Blender has an event loop, which `--background` lacks. So the script runs two child Blenders: one in `--background`, one windowed. The windowed child quits itself from a timer and reports what happened as JSON. Check-only: the witnesses are call counts, -thread identity and registration state, which no render shows. +thread idents and registration state, which no render shows. Follows [`timers-modal-and-threading`](../../skills/timers-modal-and-threading/SKILL.md), whose snippet is [`thread-queue-timer.py`](../../snippets/thread-queue-timer.py). @@ -18,10 +18,20 @@ Child-process scaffolding matches reports `is_registered() == True` and never runs. - In the windowed child, a timer returning `None` runs once, and one returning `0.05` until its third call runs three times. Neither is registered - afterwards. -- A worker thread puts its result in a `queue.Queue`; a timer drains it on the - main thread (`threading.current_thread() is threading.main_thread()`) and - creates a mesh from it. + afterwards. Registration is read **before** the file load in step 5, because + `read_factory_settings` drops every plain timer and would make the read + `False` whatever the return values were. +- Three worker threads run at once, the + [`thread-queue-timer.py`](../../snippets/thread-queue-timer.py) pattern: two + return values and one raises `ValueError`. Each worker records + `threading.get_ident()`; so does the code that touches `bpy`. Every result + and the error are handled on the script's main-thread ident, which no worker + shares. A check of `threading.current_thread() is threading.main_thread()` + inside a timer would be true by construction, since timers always run on the + main thread; comparing idents is what can fail. +- Both results are applied (a mesh each), the error is reported on the main + thread, the pending count reaches 0, and the drain timer unregisters itself: + no result is stranded and nothing polls forever. - A modal operator started with `INVOKE_DEFAULT` under `temp_override(window=...)` returns `{'RUNNING_MODAL'}`, receives three `TIMER` events from `event_timer_add`, returns `{'FINISHED'}` and removes @@ -31,32 +41,58 @@ Child-process scaffolding matches **What failure each check would catch:** -- exit 3 — the `--background` child failed, or its timer ran (the no-event-loop premise is gone) +- exit 3 — the `--background` child failed, or its timer ran + (`--windowed-background-child` lands here: the same child with a window has + an event loop, so its timer fires) - exit 4 — the windowed child wrote no result: no display, a crash, or a timeout -- exit 5 — timer return values not honoured (`--return-zero-once` lands here: - the run-once timer returns `0.0`, which re-runs it) -- exit 6 — the worker's result was not applied exactly once on the main thread +- exit 5 — timer return values not honoured (`--return-zero-once` lands here + on the call count: `0.0` re-runs the timer; `--return-late-once` lands here + on registration alone: returning `30.0` keeps the count at 1, but the timer + is still registered before the file load) +- exit 6 — a worker result or error was handled off the main thread + (`--apply-in-worker` lands here: the worker applies its own result, so the + recorded ident is the worker's) - exit 7 — the modal operator did not tick to `FINISHED` and remove its timer + (`--no-event-timer` lands here: without `event_timer_add` no `TIMER` event + ever arrives) - exit 8 — a file load did not keep only the persistent timer (`--non-persistent` lands here: the keeper is registered without `persistent=True`) +- exit 9 — not every concurrent job's result was applied (`--stop-after-first` + lands here: the old drain returns `None` after one result and strands the + other in the queue) +- exit 12 — the raising job was not reported, or the drain was still polling + (`--no-catch` lands here: the old worker dies without `put()`, the pending + count never reaches 0 and the drain stays registered) The windowed child needs a display. CI runs smoke under xvfb; on a desktop a Blender window opens for a few seconds. ## Re-verified -| Measurement | 4.5.11 | 5.1.2 | 5.2.1 | -| --- | --- | --- | --- | -| `--background` timer: registered / ran | yes / no | yes / no | yes / no | -| run-once / repeat-until-3 call counts | 1 / 3 | 1 / 3 | 1 / 3 | -| worker result applied on main thread | yes | yes | yes | -| modal: invoke, `TIMER` ticks, result, timer removed | `RUNNING_MODAL`, 3, `FINISHED`, yes | same | same | -| after file load: persistent / plain registered | yes / no | yes / no | yes / no | -| default exit | 0 | 0 | 0 | -| `--return-zero-once` / `--non-persistent` exit | 5 / 8 | 5 / 8 | 5 / 8 | - -Under `--return-zero-once` the run-once timer ran 5 times on 5.2.1 before the -file load removed it. +| Measurement | 4.5.11 | 5.2.1 | +| --- | --- | --- | +| `--background` timer: registered / ran | yes / no | yes / no | +| run-once / repeat-until-3 call counts | 1 / 3 | 1 / 3 | +| run-once / repeat registered before the file load | no / no | no / no | +| 3 workers: idents distinct from main | yes | yes | +| results applied (job: values) | heights: 3, labels: 2 | same | +| `ValueError` reported, on the main ident | yes | yes | +| pending / drain registered before the file load | 0 / no | 0 / no | +| modal: invoke, `TIMER` ticks, result, timer removed | `RUNNING_MODAL`, 3, `FINISHED`, yes | same | +| after file load: persistent / plain registered | yes / no | yes / no | +| default exit | 0 | 0 | +| `--windowed-background-child` exit | 3 | 3 | +| `--return-zero-once` / `--return-late-once` exit | 5 / 5 | 5 / 5 | +| `--apply-in-worker` / `--no-event-timer` / `--non-persistent` exit | 6 / 7 / 8 | 6 / 7 / 8 | +| `--stop-after-first` / `--no-catch` exit | 9 / 12 | 9 / 12 | + +Under `--return-zero-once` the run-once timer ran 21 times on 4.5.11 and 30 +times on 5.2.1 before the file load removed it. Under `--return-late-once` it +ran once and was still registered. Under `--stop-after-first` one result was +applied, and the drain unregistered with 2 jobs pending. Under `--no-catch` no +error was reported, the pending count stayed at 1, and the drain was still +registered. 5.1 was last measured before these cases existed (all original +checks matched 5.2.1); CI covers it on the weekly cron. ## API reference @@ -70,8 +106,14 @@ file load removed it. ```bash blender --background --python timers_modal_threading.py -- +blender --background --python timers_modal_threading.py -- --windowed-background-child blender --background --python timers_modal_threading.py -- --return-zero-once +blender --background --python timers_modal_threading.py -- --return-late-once +blender --background --python timers_modal_threading.py -- --apply-in-worker +blender --background --python timers_modal_threading.py -- --no-event-timer blender --background --python timers_modal_threading.py -- --non-persistent +blender --background --python timers_modal_threading.py -- --stop-after-first +blender --background --python timers_modal_threading.py -- --no-catch ``` ## Exit codes @@ -81,14 +123,19 @@ blender --background --python timers_modal_threading.py -- --non-persistent | 0 | Success | | 1 | Uncaught exception (FATAL wrapper) | | 2 | argparse / usage | -| 3 | `--background` child failed, or its timer ran | +| 3 | `--background` child failed, or its timer ran (`--windowed-background-child` lands here) | | 4 | Windowed child wrote no result | -| 5 | Timer return values not honoured (`--return-zero-once` lands here) | -| 6 | Worker result not applied once on the main thread | -| 7 | Modal operator did not finish and remove its timer | +| 5 | Timer return values not honoured (`--return-zero-once`, `--return-late-once` land here) | +| 6 | A worker result or error was handled off the main thread (`--apply-in-worker` lands here) | +| 7 | Modal operator did not finish and remove its timer (`--no-event-timer` lands here) | | 8 | File load did not keep only the persistent timer (`--non-persistent` lands here) | +| 9 | Not every concurrent job's result was applied (`--stop-after-first` lands here) | +| 12 | Raising job not reported, or the drain kept polling (`--no-catch` lands here) | The `blender-smoke` workflow runs the check on Blender 5.2 LTS and 4.5 LTS (5.1 on the weekly cron, the `needs-5.1` PR label, or manual dispatch). Smoke passes no extra flags on the happy path. Its catalog falsifiers are -`--return-zero-once` (expects exit 5) and `--non-persistent` (expects exit 8). +`--windowed-background-child` (exit 3), `--return-zero-once` and +`--return-late-once` (exit 5), `--apply-in-worker` (exit 6), +`--no-event-timer` (exit 7), `--non-persistent` (exit 8), +`--stop-after-first` (exit 9) and `--no-catch` (exit 12). diff --git a/examples/timers-modal-threading/timers_modal_threading.py b/examples/timers-modal-threading/timers_modal_threading.py index 50c24b92..160bcc51 100644 --- a/examples/timers-modal-threading/timers_modal_threading.py +++ b/examples/timers-modal-threading/timers_modal_threading.py @@ -8,8 +8,12 @@ the script ends, Blender exits, and the timer was only ever registered. 2. In a windowed Blender, a timer that returns ``None`` runs once and unregisters; one that returns a float runs again after that many seconds. -3. A worker thread hands its result through a ``queue.Queue``; a timer - drains it and touches ``bpy`` on the main thread. +3. Worker threads hand results through a ``queue.Queue``; a timer drains + it and touches ``bpy`` on the main thread (thread idents are compared, + because a check inside a timer is always on the main thread). Three + jobs run at once and one raises: a pending counter keeps the drain alive + until every job reports, and the worker puts the exception instead of + dying silently, so nothing is stranded and nothing polls forever. 4. A modal operator driven by ``window_manager.event_timer_add`` receives ``TIMER`` events, finishes, and removes its timer. 5. ``persistent=True`` keeps a timer registered across a file load; a plain @@ -21,7 +25,10 @@ blender --background --python timers_modal_threading.py -- blender --background --python timers_modal_threading.py -- --return-zero-once - blender --background --python timers_modal_threading.py -- --non-persistent + blender --background --python timers_modal_threading.py -- --stop-after-first + blender --background --python timers_modal_threading.py -- --no-catch + +The README lists every falsifier flag and the exit it lands on. """ import argparse import json @@ -40,6 +47,8 @@ import bpy, os def fire(): open(os.environ["BDT_TIMER_FIRED"], "w").write("fired") + if not bpy.app.background: # --windowed-background-child: quit once it has fired + bpy.ops.wm.quit_blender() return None bpy.app.timers.register(fire, first_interval=0.0) print("RESULT registered", bpy.app.timers.is_registered(fire)) @@ -50,36 +59,87 @@ def fire(): OUT = os.environ["BDT_TIMER_OUT"] FLAGS = os.environ.get("BDT_TIMER_FLAGS", "").split() T0 = time.time() -log = {"background": bpy.app.background, "once": 0, "repeat": 0, "applied": [], +MAIN_IDENT = threading.get_ident() # the script runs on Blender's main thread +log = {"background": bpy.app.background, "main_ident": MAIN_IDENT, "once": 0, "repeat": 0, + "worker_idents": [], "applied": [], "errors": [], "ticks": 0, "invoke": None, "modal_result": None, "timer_removed": False} # 2. Return value decides the next run: None unregisters, a float reschedules. def once(): log["once"] += 1 - return 0.0 if "--return-zero-once" in FLAGS else None + if "--return-zero-once" in FLAGS: + return 0.0 + if "--return-late-once" in FLAGS: + return 30.0 # runs once inside the window, yet stays registered + return None def repeat(): log["repeat"] += 1 return 0.05 if log["repeat"] < %(repeats)d else None -# 3. The worker never touches bpy; the drain timer does, on the main thread. +# 3. Workers never touch bpy; the drain timer does, on the main thread. Three +# concurrent jobs, one of which raises: the snippets/thread-queue-timer.py +# pattern (pending counter, drain everything per tick, the worker always puts). results = queue.Queue() +pending = 0 -def worker(): +def job_heights(): time.sleep(0.2) - results.put([0.0, 1.0, 2.0]) - -threading.Thread(target=worker, daemon=True).start() + return [0.0, 1.0, 2.0] + +def job_labels(): + time.sleep(0.4) + return ["a", "b"] + +def job_broken(): + time.sleep(0.3) + raise ValueError("job failed on purpose") + +def apply_result(name, value): + ident = threading.get_ident() + entry = {"job": name, "ident": ident, "values": len(value), "mesh": None} + log["applied"].append(entry) + if ident != MAIN_IDENT: + return # --apply-in-worker: identity recorded; bpy itself stays untouched off-thread + entry["mesh"] = bpy.data.meshes.new("FromWorker_" + name).name + +def worker(name, job): + log["worker_idents"].append(threading.get_ident()) + if "--no-catch" in FLAGS: # falsifier: the old worker, which dies without putting + results.put((name, True, job())) + return + try: + value = job() + except BaseException as e: + results.put((name, False, e)) + return + if "--apply-in-worker" in FLAGS: # falsifier: skip the main-thread handoff + apply_result(name, value) + return + results.put((name, True, value)) def drain(): - try: - heights = results.get_nowait() - except queue.Empty: - return 0.05 - me = bpy.data.meshes.new("FromWorker") - log["applied"].append({"main_thread": threading.current_thread() is threading.main_thread(), - "values": len(heights), "mesh": me.name}) - return None + global pending + while True: + try: + name, ok, value = results.get_nowait() + except queue.Empty: + break + pending -= 1 + if ok: + apply_result(name, value) + else: + log["errors"].append({"job": name, "ident": threading.get_ident(), "error": repr(value)}) + if "--stop-after-first" in FLAGS: + return None # falsifier: the old drain, which stops after one result + return 0.05 if pending else None + +def run_in_background(name, job): + global pending + pending += 1 + threading.Thread(target=worker, args=(name, job), daemon=True).start() + if not bpy.app.timers.is_registered(drain): + bpy.app.timers.register(drain, first_interval=0.05) # 4. A modal operator fed by an event timer. class BDT_OT_modal_ticks(bpy.types.Operator): @@ -89,7 +149,8 @@ class BDT_OT_modal_ticks(bpy.types.Operator): def invoke(self, context, event): wm = context.window_manager - self._timer = wm.event_timer_add(0.05, window=context.window) + if "--no-event-timer" not in FLAGS: # falsifier: nothing sends TIMER events + self._timer = wm.event_timer_add(0.05, window=context.window) wm.modal_handler_add(self) return {'RUNNING_MODAL'} @@ -107,9 +168,10 @@ def cancel(self, context): self._cleanup(context) def _cleanup(self, context): - context.window_manager.event_timer_remove(self._timer) - self._timer = None - log["timer_removed"] = True + if self._timer is not None: + context.window_manager.event_timer_remove(self._timer) + self._timer = None + log["timer_removed"] = True bpy.utils.register_class(BDT_OT_modal_ticks) @@ -126,8 +188,14 @@ def plain(): return 1.0 def load_file(): - if not (log["modal_result"] and log["applied"] and log["repeat"] >= %(repeats)d) and time.time() - T0 < 15: + settled = log["modal_result"] and log["repeat"] >= %(repeats)d and pending == 0 + if not settled and time.time() - T0 < 15: return 0.05 # a file load drops plain timers and ends modal operators; let them finish + # Read registration BEFORE the load: the load itself drops every plain timer, + # so a read afterwards would be False whatever the return values were. + log["still_registered"] = [bpy.app.timers.is_registered(f) for f in (once, repeat)] + log["drain_registered"] = bpy.app.timers.is_registered(drain) + log["pending"] = pending bpy.app.timers.register(keeper, first_interval=1.0, persistent="--non-persistent" not in FLAGS) bpy.app.timers.register(plain, first_interval=1.0) @@ -137,10 +205,8 @@ def load_file(): return None def finish(): - done = log["repeat"] >= %(repeats)d and log["applied"] and log["modal_result"] and "after_load" in log - if not done and time.time() - T0 < 20: + if "after_load" not in log and time.time() - T0 < 25: return 0.1 - log["still_registered"] = [bpy.app.timers.is_registered(f) for f in (once, repeat, drain)] with open(OUT, "w") as fh: json.dump(log, fh) bpy.ops.wm.quit_blender() @@ -148,12 +214,17 @@ def finish(): bpy.app.timers.register(once, first_interval=0.1) bpy.app.timers.register(repeat, first_interval=0.1) -bpy.app.timers.register(drain, first_interval=0.05) +for name, job in (("heights", job_heights), ("labels", job_labels), ("broken", job_broken)): + run_in_background(name, job) bpy.app.timers.register(start_modal, first_interval=0.2) bpy.app.timers.register(load_file, first_interval=0.3) bpy.app.timers.register(finish, first_interval=0.5, persistent=True) # must outlive the file load ''' % {"repeats": REPEATS, "ticks": MODAL_TICKS} +EXPECTED_APPLIED = {"heights": 3, "labels": 2} +CHILD_FLAGS = ("--return-zero-once", "--return-late-once", "--apply-in-worker", + "--stop-after-first", "--no-catch", "--no-event-timer", "--non-persistent") + def fail(msg, code): print(f"ERROR: {msg}", file=sys.stderr) @@ -176,16 +247,18 @@ def run_child(source, work, name, env_extra, background): return r.returncode, r.stdout + r.stderr -def check_background(work): +def check_background(work, windowed): fired = os.path.join(work, "fired.txt") - code, out = run_child(BACKGROUND_CHILD, work, "background_child", {"BDT_TIMER_FIRED": fired}, True) + code, out = run_child(BACKGROUND_CHILD, work, "background_child", {"BDT_TIMER_FIRED": fired}, + background=not windowed) registered = "RESULT registered True" in out - print(f"--background child: exit {code}, registered={registered}, fired={os.path.exists(fired)}") + print(f"{'windowed' if windowed else '--background'} timer child: exit {code}, " + f"registered={registered}, fired={os.path.exists(fired)}") if code != 0 or not registered: print(out[-2000:]) return fail("the --background child did not run or did not register its timer", 3) if os.path.exists(fired): - return fail("a timer fired in --background; the no-event-loop premise is gone", 3) + return fail("a timer fired in the background child; the no-event-loop premise is gone", 3) return 0 @@ -198,10 +271,17 @@ def check_windowed(work, flags): return fail(f"the windowed child produced no result (exit {code}); it needs a display", 4) with open(out_json, encoding="utf-8") as fh: log = json.load(fh) + main_ident = log["main_ident"] print(f"windowed child: exit {code}, background={log['background']}") print(f" timers: once ran {log['once']}x, repeat ran {log['repeat']}x, " - f"still registered (once, repeat, drain)={log['still_registered']}") - print(f" worker result applied: {log['applied']}") + f"registered before the file load (once, repeat)={log.get('still_registered')}") + print(f" threads: main={main_ident} workers={log['worker_idents']}") + print(f" applied (job, values, on main, mesh): " + f"{[(a['job'], a['values'], a['ident'] == main_ident, a['mesh']) for a in log['applied']]}") + print(f" errors (job, error, on main): " + f"{[(e['job'], e['error'], e['ident'] == main_ident) for e in log['errors']]}") + print(f" drain registered before the file load={log.get('drain_registered')} " + f"pending={log.get('pending')}") print(f" modal: invoke={log['invoke']} ticks={log['ticks']} result={log['modal_result']} " f"timer_removed={log['timer_removed']}") print(f" file load: (persistent, plain) registered before={log.get('before_load')} " @@ -209,11 +289,25 @@ def check_windowed(work, flags): if log["background"]: return fail("the windowed child reports bpy.app.background", 4) - if log["once"] != 1 or log["repeat"] != REPEATS or any(log["still_registered"]): + # Registration is read before the file load, which drops plain timers itself. + if log["once"] != 1 or log["repeat"] != REPEATS or log.get("still_registered") != [False, False]: return fail(f"timer return values were not honoured (once={log['once']}, " - f"repeat={log['repeat']}, registered={log['still_registered']})", 5) - if len(log["applied"]) != 1 or not log["applied"][0]["main_thread"] or log["applied"][0]["values"] != 3: - return fail(f"the worker result was not applied once on the main thread: {log['applied']}", 6) + f"repeat={log['repeat']}, registered={log.get('still_registered')})", 5) + # Thread identity is compared, not asserted inside a timer (which is always main). + workers = log["worker_idents"] + handled = log["applied"] + log["errors"] + if len(workers) != 3 or main_ident in workers \ + or any(h["ident"] != main_ident for h in handled): + return fail(f"worker results were not handled on the main thread: main {main_ident}, " + f"workers {workers}, handled on {[(h['job'], h['ident']) for h in handled]}", 6) + applied = {a["job"]: a["values"] for a in log["applied"] if a["mesh"]} + if applied != EXPECTED_APPLIED or len(log["applied"]) != len(EXPECTED_APPLIED): + return fail(f"not every concurrent job's result was applied once: got {applied}, " + f"expected {EXPECTED_APPLIED}", 9) + errors = [(e["job"], e["error"].split("(")[0]) for e in log["errors"]] + if errors != [("broken", "ValueError")] or log.get("pending") != 0 or log.get("drain_registered"): + return fail(f"the raising job was not reported, or the drain kept polling: errors={errors} " + f"pending={log.get('pending')} drain registered={log.get('drain_registered')}", 12) if log["invoke"] != ["RUNNING_MODAL"] or log["ticks"] != MODAL_TICKS \ or log["modal_result"] != "FINISHED" or not log["timer_removed"]: return fail("the modal operator did not run its timer ticks to FINISHED and clean up", 7) @@ -226,17 +320,28 @@ def check_windowed(work, flags): def main(): argv = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else [] p = argparse.ArgumentParser() + p.add_argument("--windowed-background-child", action="store_true", + help="falsification: run the timer-never-fires child with a window") p.add_argument("--return-zero-once", action="store_true", help="falsification: the run-once timer returns 0.0 instead of None") + p.add_argument("--return-late-once", action="store_true", + help="falsification: the run-once timer returns 30.0, so it stays registered") + p.add_argument("--apply-in-worker", action="store_true", + help="falsification: the worker applies its own result") + p.add_argument("--stop-after-first", action="store_true", + help="falsification: the drain unregisters after the first result") + p.add_argument("--no-catch", action="store_true", + help="falsification: the worker does not catch the job's exception") + p.add_argument("--no-event-timer", action="store_true", + help="falsification: the modal operator adds no event timer") p.add_argument("--non-persistent", action="store_true", help="falsification: register the keeper timer without persistent=True") args = p.parse_args(argv) - flags = [f for f, on in (("--return-zero-once", args.return_zero_once), - ("--non-persistent", args.non_persistent)) if on] + flags = [f for f in CHILD_FLAGS if getattr(args, f[2:].replace("-", "_"))] print(f"blender={bpy.app.version_string} flags={flags}") with tempfile.TemporaryDirectory(prefix="bdt_timers_") as work: - code = check_background(work) + code = check_background(work, args.windowed_background_child) if code: return code code = check_windowed(work, flags) diff --git a/skills/extension-runtime-and-packaging/SKILL.md b/skills/extension-runtime-and-packaging/SKILL.md index 15155604..93dd472c 100644 --- a/skills/extension-runtime-and-packaging/SKILL.md +++ b/skills/extension-runtime-and-packaging/SKILL.md @@ -141,7 +141,7 @@ The `--command extension` subcommands, `extension_path_user`, `online_access`, t Each example runs headless, asserts the contract, and exits non-zero when it breaks. Run one with `blender --background --python