extensive_testing round 5 did a systematic audit rather than one more
spot check, and found the one remaining gap: row VALUES are wrapped to
card width before drawing (_wrap()), bounding each draw call's cost, but
"head" block titles (built from slot id/group/node, each individually
_cap()-allowed up to 400 chars per round 4) were drawn as one unwrapped
string. PIL's text-render cost scales with string length regardless of
visible width, so 2000 slots each with a 1200-char (id+group+node)
concatenated title -- every documented cap individually honored, nothing
near any threshold -- cost 20.5s of pure font-render CPU.
Fixed by truncating head-block text to the same rendered width row values
already get, before it's ever handed to the font renderer. Round 5's own
systematic pass (tracing every slots-derived value to its draw/embed
destination, checking the wired-link path, non-string id/group/node,
by_id construction cost, host reachability, and the render kwarg) found
nothing else -- everything else that reaches the raster or the JSON embed
is capped.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Found while re-reading collect() after round 4's fix, same pattern as every
prior round: an unaudited site where caller data reaches the card uncapped.
- collect()'s per-input loop caps `value` via _short() but never capped
`name` -- the input dict's KEY is caller data too (the whole `inputs`
dict comes from the client), and flows verbatim into the drawn row label
and the embedded JSON.
- _rows_from_list's per-entry join (`f"{k}={_short(v)}"`) caps `v` but not
`k` -- a huge key still gets fully materialized into the string before
the joined result's own _short() call ever runs, so it's real per-row
cost even though the final printed string comes out short.
Both fixed with the same _cap() helper already used for slot id/group/node.
Added regression tests for both.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
extensive_testing round 4 found every cap added in rounds 1-3 bounded ROW
content only. A slot's id/group/node class, and project_name, were never
run through any length cap -- they reach render_png as "head"/"title"
block kinds, which the round-2 line-truncation loop skips entirely (it
only counted kind == "row"). Worse, collect()'s row-budget check never
fires for a slot with zero rows (empty inputs), so slot COUNT itself was
unbounded whenever each slot carries no rows.
Reproduced: 3000 slots with empty inputs and 4000-char id/group strings --
total_rows stayed at 0 the entire time, so none of the three existing caps
ever engaged -- cost 193.6s render time and produced a 12.1MB embedded
tEXt JSON, with zero truncation declared anywhere on the card.
Fixed by: capping slot id/group/node/project_name/wired-from-label length
via a new _cap() helper (same truncation as _short(), without its
display-value formatting so "no id"/"no group" fallbacks still work);
capping slot COUNT directly in collect() rather than relying on row count;
and making render_png's line-truncation loop count "head" blocks toward
the same budget as "row" blocks, as defense in depth against any other
block kind reaching render with no accompanying row. Added a test
reproducing the exact empty-input/huge-id shape, asserting on render_png's
wall-clock time and both the PNG size and the embedded JSON size.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
extensive_testing round 3 found round 2's fix was itself incomplete: it
bounded row count and per-key value length, but not the JOINED row string
_rows_from_list builds from a dict with many keys. A funpack_list entry
like {"k1": "x"*400, ..., "k150": "x"*400} produced one row whose value had
no length cap of its own -- row count and per-key length both stayed under
every existing budget while the report JSON embedded in the PNG's tEXt
chunk (which scales 1:1 with it) reached 123MB, reproducing round 2's exact
number through a different attack shape, with zero truncation declared
anywhere on the resulting card.
Fixed by running the joined row string back through _short() (closing the
length gap) and capping how many keys get processed per row at 50 before
that join even happens (closing a related CPU-amplification gap: an absurd
key count still cost one f-string+join per key before any length cap could
apply). Added a test reproducing the exact many-key-row shape and asserting
on render_png's actual wall-clock time and output size, not just collect()'s
data.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
extensive_testing round 2 found the round-1 render-cost fix was broken:
- _MAX_ROWS and _MAX_VALUE_CHARS each bounded one factor but never their
product -- 2000 rows of 400-char no-space values honored both caps
individually and still cost ~14s/~10MB per render.
- collect()'s card-wide row budget was checked once per SLOT, so one slot
with many funpack_list inputs (each independently capped at _MAX_ROWS)
could blow the total budget by 50x with no declared truncation -- 67.8s,
~123MB, and a PNG that trips Pillow's own decompression-bomb guard on
reopen. Worse than the original round-1 finding, not fixed by it.
- none of round 1's three new tests called render_png -- the function whose
cost was the actual finding -- so both regressions passed all 21 tests.
Fixed by bounding the actual cost driver directly: render_png now caps
total rendered lines at 1200 regardless of how collect() produced them
(defense in depth), and collect()'s row-budget check now runs per input,
not per slot, so one slot's inputs can no longer accumulate past the card
budget before the next slot's check fires. Added tests that call render_png
against both worst cases and assert on wall-clock time, not just collect()'s
output data.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Round 1 of extensive_testing on 6d49625 found two real defects:
- Unbounded server-side render cost/memory from a client-supplied
pipeline. render_png's image height grows with wrapped-line count,
so one crafted long input value amplified a small request into a
77,400px-tall, ~250MB render taking several seconds of single-
threaded PIL draw calls -- reproduced directly. This route is
unauthenticated, same as every sibling pipeline route, and reachable
over a rental's plain-http IP -- a real DoS surface on the box
actually doing GPU sampling. Fixed with two bounds: _short() now
truncates any one value past 400 chars (a card is a compact summary,
nobody reads a 200k-char row on it), and collect() caps the whole
card at 2000 rows, both declared on the card itself via a "…N more
omitted" note rather than silently dropped. Verified: the 200k-char
reproduction case drops from 3.2s/250MB to 0.09s/22KB; a 200,000-slot
pipeline renders bounded at ~1.7s/5MB with the truncation stated.
- Reopening "Export settings…" via the toolbar button (not the modal's
own ✕/Close) leaked the previous render's blob URL -- close()'s
revoke only runs on ITS OWN overlay, and the top-of-function DOM
cleanup only removed old .sc-overlay nodes, never revoked their
blobs. Carried over unfixed from v4's identical pattern. Fixed by
stashing the blob URL on its own overlay element so the cleanup line
can find and revoke it. Verified live: two clicks without ever
hitting Close produces two distinct blob URLs and exactly one
revokeObjectURL call, one overlay left in the DOM.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
MEDIUM-tier v4 feature ("Which model was that?" answered three days
later): a button in Models & Pipeline renders the currently loaded
pipeline as a PNG -- every slot's node class and typed-in values, wired
inputs marked as wired rather than printed as a stale value, and the
host facts (torch/CUDA/GPU/attention) that decide whether the setup
reproduces elsewhere. Downloadable, copyable to clipboard, and the
report is embedded in the PNG's own tEXt chunk for later machine
reading.
core/settings_card.py is a hard cut of v4's version, not a verbatim
port: v4 spent most of its length (_OWNED_BY, _EDITOR_DEFAULTS,
_pass_rows, _sampling_sections) describing FunPackStudio and the
LTX-AV Scene Chain Sampler's own hardcoded knob tables -- neither node
exists in v5's module-driven pipeline, so there is nothing left to
describe there. What's genuinely generic (walking a slot's real
inputs, detecting a wired one via core.graph.is_link, the PNG
renderer) is what's kept, adapted to v5's slot shape ({id, node,
group, inputs}, a wire arriving as [source_id, index] rather than a
separate input_sources/wires lookup) and v5's existing core/sysinfo.py
for host facts (whose field names already matched v4's needs).
New POST /api/settings-card route: stateless like /api/pipeline, the
CLIENT sends its own live pipeline (window.PipelineState.slots()) since
that's the one copy that includes any unsaved edit; the server supplies
only what it alone knows (host facts) and the render.
Frontend: app/legacy/api.js's settingsCard stub (present since the
Phase A port, explicitly marked "not built in v5 yet") replaced with a
real POST+blob call; app/legacy/models.js's Export-settings modal
ported from v4 near-verbatim, reading PS.slots()/project name instead
of a project id the old per-project models config needed. Verified
live: real pipeline, real host facts (this Mac's actual Apple MPS/
Python/ComfyUI versions), wired inputs correctly marked.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>