Skip to content

Commit 4aeb805

Browse files
TMHSDigitalclaude
andauthored
fix(examples): handle N jobs and job errors in thread-queue-timer; make three checks falsifiable (#478)
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 <tmhospitalitystrategies@gmail.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 35513e8 commit 4aeb805

14 files changed

Lines changed: 469 additions & 142 deletions

File tree

‎docs/gallery/image-pixels-testcard/index.html‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -65,23 +65,23 @@ <h1>Image Pixels Testcard</h1>
6565
<p><strong>What it witnesses:</strong> the <code>bpy.types.Image</code> pixel-buffer contract. <code>Image.pixels</code> is a flat, row-major, bottom-left-origin float buffer that is <em>always</em> RGBA: <code>channels == 4</code> and <code>len(pixels) == width × height × 4</code> even when the image is created with <code>alpha=False</code>, so an RGB-stride <code>foreach_set</code> raises <code>TypeError</code> instead of writing. A byte image (the default) stores 8 bits per channel — every written float round-trips with error ≤ 0.5/255 <strong>and strictly &gt; 0</strong> (an exact round-trip would mean storage is not 8-bit); <code>float_buffer=True</code> stores float32 and round-trips at ~1e-7. <code>Image.scale()</code> reallocates the buffer, so a <code>foreach_get</code> into a stale-size list raises <code>TypeError</code> rather than silently shearing rows.</p>
6666
<p><strong>The <code>save()</code> trap</strong> (found while authoring — identical on 4.5 LTS and 5.1): <code>Image.save()</code> on a <code>GENERATED</code> image silently flips <code>source</code> to <code>&#x27;FILE&#x27;</code> and drops the in-memory buffer (<code>has_data</code> becomes <code>False</code>). Every later <code>pixels</code> read re-loads from whatever currently sits at <code>filepath_raw</code>. The check proves it by overwriting the file with a flat-gray imposter image <em>after</em> <code>save()</code> and reading the imposter&#x27;s pixels back through the original datablock. <code>save_render()</code> writes the same PNG but is non-destructive: <code>source</code> stays <code>&#x27;GENERATED&#x27;</code> and the buffer stays exact. Scripts that write pixels, <code>save()</code>, then keep computing on <code>pixels</code> are silently computing on a decoded PNG.</p>
6767
<p><strong>What each check catches on failure:</strong></p>
68-
<ul><li><em>Buffer geometry</em> — an API change to per-image channel counts, or code assuming an RGB stride (falsified: a <code>W*H*3</code> write raises and the check exits 3).</li><li><em>Byte/float round-trip vs the closed-form card</em> — 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.</li><li><em>Quantization floor</em> — <code>byte_err &gt; 0</code> proves 8-bit storage really quantizes; byte and float images swapping behavior cannot hide.</li><li><em>Reallocation</em> — <code>scale()</code> no longer reallocating (stale-size read succeeding).</li><li><em>save() lifecycle</em> — the source-flip/buffer-drop behavior changing (falsified: substituting <code>save_render()</code> for <code>save()</code> exits 7 because <code>source</code> stays <code>GENERATED</code>).</li><li><em>Disk round-trip</em> — a byte sRGB image saved to PNG and reloaded must match at quantization tolerance.</li></ul>
68+
<ul><li><em>Buffer geometry</em> — an API change to per-image channel counts, or code assuming an RGB stride (falsified: a <code>W*H*3</code> write raises and the check exits 3).</li><li><em>Byte/float round-trip</em> — 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 <code>foreach_set</code> → <code>foreach_get</code> round trip reads back the order it wrote, so it cannot see row order; that is the next check&#x27;s job.</li><li><em>Row order through a real PNG</em> — the byte image is saved with <code>Image.save()</code> and the file&#x27;s rows are decoded with <code>zlib</code> + <code>struct</code> alone, no <code>bpy</code>. PNG stores the top row first, so a bottom-left origin puts pixel (0, 0) — the origin marker — in the file&#x27;s <strong>last</strong> row, and every PNG row <code>k</code> must match card row <code>H-1-k</code> 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). <code>--wrong-origin</code> 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.</li><li><em>Quantization floor</em> — <code>byte_err &gt; 0</code> proves 8-bit storage really quantizes; byte and float images swapping behavior cannot hide.</li><li><em>Reallocation</em> — <code>scale()</code> no longer reallocating (stale-size read succeeding).</li><li><em>save() lifecycle</em> — the source-flip/buffer-drop behavior changing (falsified: substituting <code>save_render()</code> for <code>save()</code> exits 7 because <code>source</code> stays <code>GENERATED</code>).</li><li><em>Disk round-trip</em> — a byte sRGB image saved to PNG and reloaded must match at quantization tolerance.</li></ul>
6969
<p><strong>Version divergence:</strong> none — every contract above, including the <code>save()</code> 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 (<code>BLENDER_EEVEE_NEXT</code> on 4.x, <code>BLENDER_EEVEE</code> on 5.x).</p>
7070
<p><strong>Render hazard worth knowing:</strong> a bmesh-built plane has no UV map, and <code>bmesh.ops.create_grid(..., calc_uvs=True)</code> 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.</p>
7171
<h2 id="run">Run<a class="anchor" href="#run" aria-label="Link to this section">#</a></h2>
7272
<pre tabindex="0"><code># Cheap correctness check (no render) — the CI check:
7373
blender --background --python image_pixels_testcard.py --
7474

75-
# Falsifier: write the card top-down. Must exit non-zero (byte round-trip).
75+
# Falsifier: write the card top-down. Must exit 13 (PNG row order).
7676
blender --background --python image_pixels_testcard.py -- --wrong-origin
7777

7878
# Also render a still (EEVEE on a GPU host; use --engine cycles on GPU-less hosts):
7979
blender --background --python image_pixels_testcard.py -- --output card.png
8080
blender --background --python image_pixels_testcard.py -- --output card.png --engine cycles</code></pre>
8181
<h2 id="exit-codes">Exit codes<a class="anchor" href="#exit-codes" aria-label="Link to this section">#</a></h2>
8282
<p>Per-script sequential checks. <code>9</code> is a valid check code; there is no rule against it.</p>
83-
<div class="table-wrap"><table><thead><tr><th scope="col">Code</th><th scope="col">Meaning</th></tr></thead><tbody><tr><td>0</td><td>Success</td></tr><tr><td>1</td><td>Uncaught exception (FATAL wrapper)</td></tr><tr><td>2</td><td>argparse / usage</td></tr><tr><td>3</td><td>Pixel buffer is not always RGBA</td></tr><tr><td>4</td><td>Byte round-trip vs closed-form card (<code>--wrong-origin</code> lands here)</td></tr><tr><td>5</td><td>Float-buffer round-trip failed</td></tr><tr><td>6</td><td><code>scale()</code> did not reallocate, or stale-size read succeeded</td></tr><tr><td>7</td><td><code>save()</code> source/buffer-drop contract drifted</td></tr><tr><td>8</td><td><code>save_render()</code> flipped source or disturbed the buffer</td></tr><tr><td>9</td><td>Byte PNG save/reload error</td></tr><tr><td>10</td><td>Framing gate violation on the <code>--output</code> path (<code>gallery_framing</code>)</td></tr><tr><td>12</td><td><code>--output</code> produced no file</td></tr></tbody></table></div>
84-
<p>The <code>blender-smoke</code> workflow runs the check on Blender 5.2 LTS and 4.5 LTS (5.1 on the weekly cron, the <code>needs-5.1</code> PR label, or manual dispatch). Smoke does not pass <code>--output</code>. Its catalog falsifier is <code>--wrong-origin</code> (expects exit 4).</p>
83+
<div class="table-wrap"><table><thead><tr><th scope="col">Code</th><th scope="col">Meaning</th></tr></thead><tbody><tr><td>0</td><td>Success</td></tr><tr><td>1</td><td>Uncaught exception (FATAL wrapper)</td></tr><tr><td>2</td><td>argparse / usage</td></tr><tr><td>3</td><td>Pixel buffer is not always RGBA</td></tr><tr><td>4</td><td>Byte round-trip error or no quantization</td></tr><tr><td>5</td><td>Float-buffer round-trip failed</td></tr><tr><td>6</td><td><code>scale()</code> did not reallocate, or stale-size read succeeded</td></tr><tr><td>7</td><td><code>save()</code> source/buffer-drop contract drifted</td></tr><tr><td>8</td><td><code>save_render()</code> flipped source or disturbed the buffer</td></tr><tr><td>9</td><td>Byte PNG save/reload error</td></tr><tr><td>10</td><td>Framing gate violation on the <code>--output</code> path (<code>gallery_framing</code>)</td></tr><tr><td>12</td><td><code>--output</code> produced no file</td></tr><tr><td>13</td><td>Saved PNG rows do not put pixel (0, 0) at the bottom-left (<code>--wrong-origin</code> lands here)</td></tr></tbody></table></div>
84+
<p>The <code>blender-smoke</code> workflow runs the check on Blender 5.2 LTS and 4.5 LTS (5.1 on the weekly cron, the <code>needs-5.1</code> PR label, or manual dispatch). Smoke does not pass <code>--output</code>. Its catalog falsifier is <code>--wrong-origin</code> (expects exit 13).</p>
8585
<p>In the render, <code>Closest</code> 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 <code>docs/VISUAL-STYLE.md</code> (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&#x27;s values read exactly. The render path gates framing through <code>examples/gallery_framing.py</code> (exit 10) before writing; the desk is passed as stage, like the floor, because its crop at the frame bottom is the composition.</p>
8686
</section>
8787
<section class="detail-section src" id="source">

‎examples/extension-package-lifecycle/README.md‎

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -36,10 +36,12 @@ Scaffolding matches [`eval-mesh-datablock-name`](../eval-mesh-datablock-name/)
3636
**What failure each check would catch:**
3737

3838
- exit 3 — the template stopped validating or building, or the zip lost its manifest
39-
- exit 4 — a package whose wheel does not exist passed the release gate
40-
(`--validate-only` lands here: it gates on `validate` alone); also exit 4 if
41-
`validate` starts rejecting the missing wheel, because the trap this example
42-
names would then be gone
39+
- exit 4 — `build` accepted the package whose manifest names a wheel it does
40+
not ship, so nothing between `validate` and a release would catch it
41+
(`--ship-wheel` lands here: it creates the named wheel, so `build` exits 0
42+
and writes a zip, proving the rejection comes from the missing file); also
43+
exit 4 if `validate` starts rejecting the package, because the trap this
44+
example names would then be gone
4345
- exit 5 — `server-generate` wrote no listing, or the listing does not name the package
4446
- exit 6 — install, import, upgrade or remove failed, or `__package__` is not
4547
`bl_ext.user_default.example_addon`
@@ -60,7 +62,8 @@ Scaffolding matches [`eval-mesh-datablock-name`](../eval-mesh-datablock-name/)
6062
| user data after `remove` | deleted | deleted | deleted |
6163
| `online_access` default / `--online-mode` | False / True | False / True | False / True |
6264
| default exit | 0 | 0 | 0 |
63-
| `--validate-only` / `--data-next-to-file` exit | 4 / 7 | 4 / 7 | 4 / 7 |
65+
| `--ship-wheel` / `--data-next-to-file` exit | 4 / 7 | not re-run / 7 | 4 / 7 |
66+
| validate / build, wheel shipped (`--ship-wheel`) | 0 / 0, zip written | not re-run | 0 / 0, zip written |
6467

6568
## API reference
6669

@@ -73,7 +76,7 @@ Scaffolding matches [`eval-mesh-datablock-name`](../eval-mesh-datablock-name/)
7376

7477
```bash
7578
blender --background --python extension_package_lifecycle.py --
76-
blender --background --python extension_package_lifecycle.py -- --validate-only
79+
blender --background --python extension_package_lifecycle.py -- --ship-wheel
7780
blender --background --python extension_package_lifecycle.py -- --data-next-to-file
7881
```
7982

@@ -88,7 +91,7 @@ than most examples.
8891
| 1 | Uncaught exception (FATAL wrapper) |
8992
| 2 | argparse / usage |
9093
| 3 | Template does not validate or build, or the zip lacks its manifest |
91-
| 4 | Missing-wheel package passed the release gate (`--validate-only` lands here) |
94+
| 4 | `build` accepted the missing-wheel package, or `validate` rejected it (`--ship-wheel` lands here) |
9295
| 5 | `server-generate` listing missing or wrong |
9396
| 6 | Install, import, upgrade or remove failed, or `__package__` wrong |
9497
| 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.
97100
The `blender-smoke` workflow runs the check on Blender 5.2 LTS and 4.5 LTS
98101
(5.1 on the weekly cron, the `needs-5.1` PR label, or manual dispatch).
99102
Smoke passes no extra flags on the happy path. Its catalog falsifiers are
100-
`--validate-only` (expects exit 4) and `--data-next-to-file` (expects exit 7).
103+
`--ship-wheel` (expects exit 4) and `--data-next-to-file` (expects exit 7).

‎examples/extension-package-lifecycle/extension_package_lifecycle.py‎

Lines changed: 19 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@
2020
Check-only: no gallery still. The witnesses are exit codes and paths.
2121
2222
blender --background --python extension_package_lifecycle.py --
23-
blender --background --python extension_package_lifecycle.py -- --validate-only
23+
blender --background --python extension_package_lifecycle.py -- --ship-wheel
2424
blender --background --python extension_package_lifecycle.py -- --data-next-to-file
2525
"""
2626
import argparse
@@ -92,24 +92,32 @@ def check_build(work):
9292
return 0, dist
9393

9494

95-
def check_wheel_trap(work, validate_only):
95+
def check_wheel_trap(work, ship_wheel):
9696
"""validate parses the manifest; only build opens the files it names."""
9797
src = copy_template(os.path.join(work, "wheel_src"))
9898
manifest = os.path.join(src, "blender_manifest.toml")
9999
with open(manifest, "a", encoding="utf-8") as fh:
100100
fh.write(f'\nwheels = ["{MISSING_WHEEL}"]\n')
101+
if ship_wheel: # falsification: the named wheel exists, so build has nothing to reject
102+
wheel = os.path.normpath(os.path.join(src, MISSING_WHEEL))
103+
os.makedirs(os.path.dirname(wheel))
104+
with zipfile.ZipFile(wheel, "w") as z:
105+
z.writestr("not_shipped/__init__.py", "")
106+
z.writestr("not_shipped-1.0.dist-info/METADATA",
107+
"Metadata-Version: 2.1\nName: not_shipped\nVersion: 1.0\n")
101108
out_dir = os.path.join(work, "wheel_dist")
102109
os.makedirs(out_dir)
103110
v_code, _ = blender(["--command", "extension", "validate", src])
104111
b_code, b_out = blender(["--command", "extension", "build", "--source-dir", src, "--output-dir", out_dir])
105112
shipped = os.listdir(out_dir)
106-
print(f"missing wheel: validate exit {v_code}, build exit {b_code}, output {shipped}")
113+
print(f"{'shipped' if ship_wheel else 'missing'} wheel: validate exit {v_code}, "
114+
f"build exit {b_code}, output {shipped}")
107115
if v_code != 0:
108-
return fail("validate now rejects a missing wheel; the trap this example names is gone", 4)
109-
gate_passed = v_code == 0 if validate_only else (b_code == 0 and bool(shipped))
110-
if gate_passed:
111-
return fail("a package whose wheel does not exist passed the release gate "
112-
f"({'validate only' if validate_only else 'build'})", 4)
116+
return fail("validate rejects the package; the validate-is-not-a-gate trap this "
117+
"example names is gone", 4)
118+
if b_code == 0 or shipped:
119+
return fail(f"build accepted the package (exit {b_code}, output {shipped}); "
120+
"only build can reject a wheel the manifest names but the source lacks", 4)
113121
return 0
114122

115123

@@ -222,8 +230,8 @@ def check_online(user_dir):
222230
def main():
223231
argv = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else []
224232
p = argparse.ArgumentParser()
225-
p.add_argument("--validate-only", action="store_true",
226-
help="falsification: gate the release on validate alone")
233+
p.add_argument("--ship-wheel", action="store_true",
234+
help="falsification: create the wheel the manifest names, so build succeeds")
227235
p.add_argument("--data-next-to-file", action="store_true",
228236
help="falsification: store user data beside __file__")
229237
args = p.parse_args(argv)
@@ -235,7 +243,7 @@ def main():
235243
code, dist = check_build(work)
236244
if code:
237245
return code
238-
code = check_wheel_trap(work, args.validate_only)
246+
code = check_wheel_trap(work, args.ship_wheel)
239247
if code:
240248
return code
241249
code = check_listing(dist)

‎examples/image-pixels-testcard/README.md‎

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -29,9 +29,18 @@ decoded PNG.
2929

3030
- *Buffer geometry* — an API change to per-image channel counts, or code assuming an
3131
RGB stride (falsified: a `W*H*3` write raises and the check exits 3).
32-
- *Byte/float round-trip vs the closed-form card* — any stride, orientation, or
33-
packing bug in the bulk path; a one-pixel shift was deliberately introduced once and
34-
the check exited 4 with measured error 0.97 against tolerance 0.00196.
32+
- *Byte/float round-trip* — a stride or packing bug in the bulk path; a one-pixel
33+
shift was deliberately introduced once and the check exited 4 with measured error
34+
0.97 against tolerance 0.00196. A `foreach_set` → `foreach_get` round trip reads
35+
back the order it wrote, so it cannot see row order; that is the next check's job.
36+
- *Row order through a real PNG* — the byte image is saved with `Image.save()` and
37+
the file's rows are decoded with `zlib` + `struct` alone, no `bpy`. PNG stores the
38+
top row first, so a bottom-left origin puts pixel (0, 0) — the origin marker — in
39+
the file's **last** row, and every PNG row `k` must match card row `H-1-k` at
40+
quantization tolerance. Measured: the marker sits in PNG rows 247..287 of 0..287
41+
and the rows match at 0.0019608 (tolerance 0.0019618). `--wrong-origin` writes the
42+
card top-down through Blender: the marker lands in PNG rows 0..40, the row error
43+
is 0.9216, and the check exits 13.
3544
- *Quantization floor* — `byte_err > 0` proves 8-bit storage really quantizes;
3645
byte and float images swapping behavior cannot hide.
3746
- *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
5766
# Cheap correctness check (no render) — the CI check:
5867
blender --background --python image_pixels_testcard.py --
5968

60-
# Falsifier: write the card top-down. Must exit non-zero (byte round-trip).
69+
# Falsifier: write the card top-down. Must exit 13 (PNG row order).
6170
blender --background --python image_pixels_testcard.py -- --wrong-origin
6271

6372
# Also render a still (EEVEE on a GPU host; use --engine cycles on GPU-less hosts):
@@ -76,18 +85,19 @@ against it.
7685
| 1 | Uncaught exception (FATAL wrapper) |
7786
| 2 | argparse / usage |
7887
| 3 | Pixel buffer is not always RGBA |
79-
| 4 | Byte round-trip vs closed-form card (`--wrong-origin` lands here) |
88+
| 4 | Byte round-trip error or no quantization |
8089
| 5 | Float-buffer round-trip failed |
8190
| 6 | `scale()` did not reallocate, or stale-size read succeeded |
8291
| 7 | `save()` source/buffer-drop contract drifted |
8392
| 8 | `save_render()` flipped source or disturbed the buffer |
8493
| 9 | Byte PNG save/reload error |
8594
| 10 | Framing gate violation on the `--output` path (`gallery_framing`) |
8695
| 12 | `--output` produced no file |
96+
| 13 | Saved PNG rows do not put pixel (0, 0) at the bottom-left (`--wrong-origin` lands here) |
8797

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

92102
In the render, `Closest` interpolation keeps the pixel grid honest —
93103
the jagged circle edge is the 512 × 288 buffer itself, and the white marker in the

0 commit comments

Comments
 (0)