Skip to content

Stability review: nine findings closed, one rejected, one observation overturned - #45

Merged
donislawdev merged 8 commits into
mainfrom
cli/planning-can-be-stopped
Sep 3, 2026
Merged

Stability review: nine findings closed, one rejected, one observation overturned#45
donislawdev merged 8 commits into
mainfrom
cli/planning-can-be-stopped

Conversation

@donislawdev

@donislawdev donislawdev commented Sep 3, 2026

Copy link
Copy Markdown
Owner

An outside session reviewed this tool for stability and reported ten findings
and three observations. This branch is the answer to all of them: a verdict with
a measurement for each, and the work for the ones that survived it.

What changed for somebody running tfg

  • Ctrl+C stops generate, validate and preset show while they are still
    planning.
    Ten thousand pictures is about a minute and a half of planning
    before the first byte, and all of it used to ignore the key.
  • A compressed zip is either produced at the size you asked for or refused.
    Two sizes did neither, and both passed --dry-run first.
  • A recipe can no longer use up all the memory on the machine. Forty
    kilobytes nesting brackets twenty thousand deep took most of a gigabyte.
  • A run of a few files is weighed against the memory ceiling too. It only
    started counting at sixty four files, so a shorter run had no ceiling at all.
  • A crash inside a generator costs one file instead of the whole run, and no
    longer leaves a temporary file that cleanup will never remove.

Runs this tool was designed around are unaffected, and no generated file changes
its bytes.

What the measurements said

Every fix here started from a number rather than from the report's reasoning,
and three of the report's conclusions were right for the wrong reason.

  • The plan memory gap was not "a run under sixty four files". The count was
    summed across targets, so it was the first up to sixty three files of every
    run, however long. A zip of ten thousand pdf entries costs 74 740 758 B of plan
    a file - five points, linear to within 0.05% - so twenty nine of them are
    2.17 GB against a ceiling of two, unweighed.
  • Waiting until sixty four was defended by a number that turned out to describe
    something else. Four interleaved runs of the guard package: 277.9 s and 283.7 s
    before, 292.3 s and 306.1 s after, so about six percent.
  • The free space overflow was reported for the unix file. Windows had the same
    conversion.
  • One observation was overturned: holding a cases.Caser instead of building
    one per call changes neither the allocation count nor the byte count.
  • One finding is rejected outright and the reasoning is written down, so it
    does not get reopened without a new argument.

Two measurements were deferred for want of machine memory and then done once it
came back, rather than left as estimates:

  • The ceiling being crossed for real. A run of that zip shape is refused at
    the twenty ninth file
    , which is exactly where five points said it would be.
  • Ctrl+C reaching planning on Windows, not only on Linux: 19.72 s without a
    signal, 2.01 s with one, exit 130.

And one comment was corrected that had been on this work's own list and missed:
targz said compression only ever makes contents smaller. It does not, and that
is the assumption zip fell over on. What keeps targz to the byte is that it
measures rather than that the sentence was true.

What was deliberately not done

  • defer fh.Close() beside the new recover. Once the panic is an error, the
    close and the remove that already exist run - nothing could turn that defer
    red, and this project has taken seven such pieces back out.
  • A refusal at planning time for the compressed zip band. The worst case for
    deflate growth is about 165 B at 2 MB and the measured band is 50 B, so a
    refusal with that margin would reject sizes that work today.
  • Two checks that could not be reached at all were left out until their
    arithmetic could be asked directly, which is why one of them moved into a pure
    function first.

Guards

Every changed behaviour has a guard, and every guard is proven by a mutation
that turns it red. The mutation list went from 735 entries to 750, and four
existing entries were retargeted or replaced after the refactors moved code out
from under them - the staleness check names those in no time at all, which is
why it runs after every refactor rather than at the end.

🤖 Generated with Claude Code

donislawdev and others added 8 commits September 2, 2026 22:55
…recipe

engine.PlanContext was written so planning could be stopped and its own
comment says why - ten thousand pngs is a minute and a half before a byte
is written. Only the window called it. All three planning call sites in the
command line called engine.Plan, which is PlanContext(context.Background()),
so the context the process builds for the signal reached the writing and
stopped at the planning.

Measured on Linux with a real SIGINT, 4000 pngs, --dry-run, signal sent at
t=2.0 s: main ended at 51.99 s, this ends at 2.010 s. Both exit 130.

A stopped validate --json no longer reports "valid": false. It never
finished reading the recipe, so it has no verdict to give, and a consumer
reading that one field would act on a claim about the file rather than on
what happened.

validate was split into planningRefusal to stay under the crowding
threshold, which the guard asks for rather than raising the cap.

Three guards, all proven by mutation. The second exists because the first
passed for generate while planning was still uninterruptible: preflight
noticed the context on the way to writing, so the exit code was right and
the work had all been done anyway. It tells them apart with two exit codes
on one input rather than with a clock.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…words

Two shapes produced a file of the wrong length with no error, and both
passed --dry-run first.

At exactly the bare size, pad() took its "nothing to pad" early return
before reaching padCompressed, so withFiller stayed false - and the filler
entry is the only thing that can give back what the compressor freed.
Measured: 8382 B produced about 2.6 kB at every level but none. The floor
for a compressed zip moves 8382 -> 8522, which is where sizes started
working anyway.

freed() can be NEGATIVE. Deflate grows data it cannot shrink, so an archive
holding already compressed files comes out larger than it went in, and the
filler cannot shrink below zero to compensate. writeFiller simply wrote
nothing. Measured as a band 50 B wide holding two 1 MB docx entries. It is
now a BelowMinimumError naming the measured floor.

Refused at write time rather than at planning, which is where targz refuses
the same thing for the same reason: how far contents squeeze is not knowable
without squeezing them. Refusing at plan time would need the worst case,
about 165 B at 2 MB against a measured band of 50 B, so it would refuse
sizes that work.

The report also asked for an n < 0 check inside writeFiller. Not done, and
the reason is recorded: after this, nothing could redden it.

A guard for this already existed and was honestly green.
TestACompressedArchiveStillHitsTheSizeToTheByte names this exact failure and
samples three round sizes with the contents left at their default. A round
size never lands on the floor, and the default contents are text, which
deflate shrinks - so neither shape can occur where it looks.

Two mutations, both caught. A third, pre-existing entry was retargeted after
gofmt moved the spaces in the line it named.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…e machine has

Nesting flow collections twenty thousand deep took 918 MB of heap on a 40 kB
file, which is a twenty fifth of what MaxBytes allows. The comment on that
limit claimed it bounded the work - "a megabyte caps the worst case at
seconds rather than minutes" - and the size of a document says nothing about
its shape. After this: 22.7 MB and exit 3, in our own words.

The ceiling is on DEPTH, and that is measured rather than preferred. Three
other measures were tried and each let a legal recipe look like the bomb:
token count (a legal thousand-target recipe has 10005, the bomb 3919 - the
legal one has MORE), flow marker count (2000 against 3900), and the number
of collections. Depth separates cleanly: legal recipes reach one, the bomb
reaches twenty thousand.

The lexer is asked rather than the bytes. Forty thousand brackets inside one
quoted value come back as depth nought, which a byte scan could only manage
by reimplementing YAML's quoting rules - a second parser beside the one
being defended against.

A budget on reading time was written for a second shape and taken back out,
because that shape does not exist. A chain of two thousand bracket pairs
looked like it never finished and reads in 0.19 s. What hung was the harness
measuring it: it wrote a 4 kB refusal into a pipe nothing was reading, and
the pipe holds 4096 bytes - which is exactly where the apparent cliff sat,
between a 4010 B message and a 4110 B one.

Two mutations, both caught. The legal halves of the guard are load bearing:
without them it would pass for a limit that refuses everything.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…nested three deep

flowDepth held a loop, a switch and a comparison inside one another, which is
three levels, and the shape guard counts how many functions sit that deep as
well as how deep the deepest one is. The count had reached fifty three against
a cap of fifty two, so the whole suite was red on a branch whose targeted runs
were all green.

The switch moves into depthChange, which answers what one token does to the
nesting and nothing else. Behaviour is unchanged - the deepest value is only
ever written where it was written before, on a token that opens a collection.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…is, and a generator that crashes costs one file

Two findings from the stability review, both in the engine.

The memory ceiling on a plan only started counting once a run had announced
sixty four files, so a shorter run had no ceiling at all - and the count was
the whole run's, added up across targets, so sixty four targets of one file
each took their reference point after sixty three files were already planned,
and those sat inside it for the rest of the run.

Measured rather than reasoned about: a zip of ten thousand pdf entries costs
74 740 758 B of plan a file, five points from one file to sixteen, linear to
within 0.05%. Twenty nine of them come to 2.17 GB against a ceiling of two,
and nothing weighed them. The reference point is now taken when the budget is
built and the first reading is at the first file, so expect, expected, started
and planCheckFirst all go. What waiting bought was measured too, because the
number in the comment that justified it turned out to be about something else:
the guard package runs 277.9 s and 283.7 s without the change against 292.3 s
and 306.1 s with it, so about six percent.

The second is that a panic inside a generator ended the process and left the
file it was writing on the disk under its temporary name - a name cleanup will
not remove, because cleanup only removes what the manifest lists. Such a crash
is now an ordinary failure of one file. Planning is wrapped as well as writing,
because the picture formats encode while planning and that is the likelier of
the two, and a crash there ends the run with the code that means this tool has
a defect. defer fh.Close() was asked for by the review and left out on purpose:
once the panic is an error the existing close and remove run, and nothing could
turn that defer red.

Five mutations, all caught. The file ceiling drops from 503 to 502 because
engine.go lost a line.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ange now have a guard that can reach them

Three small findings from the stability review, and two of them turned on being
able to turn the fix red.

Free space came back from both platform files as a number the system reports
unsigned, converted to int64. At eight exbibytes that wraps into a negative, and
the free space check reads a negative as a disk too small for anything - so the
tool would refuse to write to the largest disk it will ever meet. Nobody has
such a disk, which is the argument for where the fix lives rather than a reason
to skip it: a condition inside the syscall is one nothing could ever reach, so
the arithmetic is now core.AvailableFrom in a file with no build tag, and the
guard asks it directly on every platform. The review only named the unix side.
Windows had the same conversion, one multiplication shorter.

AppendFiller ends its loop only by adding bytes, so a vocabulary of empty
strings with a separator that returns nothing spins without end and without
growing - the one failure a size guard cannot see, because no file is produced
to measure. FillRecords three functions up names exactly that and refuses. The
obvious fix was wrong: checking progress every iteration refuses ["", "ab"],
which pads perfectly well. The check is about the vocabulary having anything to
say at all, and one word with bytes in it ends the loop whatever the separator
does.

The third is two sentences. The comment on stop said it waits for the run, and
what it waits on is the worker - the last widget writes may still be queued,
because fyne.Do queues rather than runs. Settled carries the same limit, since
it waits on the same channel, and the first draft of this correction said
otherwise.

Four mutations, all caught.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
golangci-lint reads a switch on token.Type without a default as incomplete, and
it is right about the shape while being wrong about this function: everything
that is not a bracket or a brace leaves the nesting where it was, and listing
the other thirty members would say less than one line saying so.

Measured rather than assumed, because the natural conclusion was that flattening
the function had introduced this. It had not. The version before the flattening,
restored from 3a32c9d and run through the same pinned linter, reports the same
thing at line 78 - the branch had simply never been through preflight, which is
also how a shape cap sat one over its measurement for three commits.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…nnot

The comment on reachable said "compression only ever makes the contents
smaller, so an archive that fits when stored fits when squeezed". That is false,
and it is the exact assumption zip fell over on: deflate grows data that is
already compressed. Measured on 2026-09-02 with two Office documents inside a
zip, a band 50 B wide at 2 MB where the space compression freed came out
negative and the file was written short of its size.

What keeps this format to the byte is settleCompressed, which measures the real
stream and iterates. reachable is a cheap early refusal, and now says so.

This was on the list of the piece that fixed zip and was missed there -
internal/format/targz was never touched by this branch until now.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@donislawdev
donislawdev merged commit e8cd383 into main Sep 3, 2026
18 checks passed
@donislawdev
donislawdev deleted the cli/planning-can-be-stopped branch September 3, 2026 11:22
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant