Skip to content

Commit 9df8791

Browse files
donislawdevclaude
andauthored
fix: the window says when a run's record will be too big to read back (#81)
The command line has warned about this since the ceiling was measured on 2026-08-26. The window said nothing at all: TooLargeToReadBack had two callers and not one of them was in internal/gui. So somebody who generated 25 000 files from the window ended up with a directory that tfg verify and tfg cleanup both refuse, and no warning anywhere. The manifest is the only authority over what may be removed, so those files could never be cleaned up by this tool again. It is the kind of parity gap D1 loses most easily. Not something the engine can do from one surface and not the other, which is what the parity guard looks for, but something one surface SAYS and the other does not - and both surfaces reach the same engine, so nothing there could see it. The observation recorded this as a question about the manifest schema, on the grounds that notes are per file and this one is per run. That premise was false, and finding out was most of the work. The command line does not read this off the manifest either: it works it out from the plan and prints it before the first byte. manifest.TooLargeToReadBack was put where it is exactly so the two surfaces could not come to different conclusions about one run. What was missing was a caller. The schema does not move and manifest_version stays 1.0. One method arrives, (*Manifest).ReadBackReach, because the count of entries carrying a note is already kept while entries are added. Working it out a second time would mean walking every file again and getting it subtly wrong when a failed entry gains a note of its own. The window says it in two places, because it cannot say anything in the middle of a run - a widget touched from a worker is a race, and two of those were found on CI: - after Preview, which is the window's answer to --dry-run. engine.Run with DryRun builds the whole document, so the answer is there for the asking. - when a run finishes, immediately after the line saying what the run did and ahead of any other note. This is the one that matters: Preview is a button somebody may never press, and that person is the one left with the directory. Four guards, four mutations. The pair for Preview was written wrong first and the probe said so. It set a 200 B size while the window was on its default format, which is the first in the registry - avif - so both previews were REFUSED. The negative half passed while proving nothing, because a screen saying "check the settings marked above" says nothing about a manifest ceiling either. Both now choose txt and both assert the preview was accepted before reading anything into what it said. The guard for a finished run costs 26 s, because it really writes 22 345 files. runFinished has no cheaper seam, and this is the only guard covering the case the observation is actually about. Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
1 parent 96c6b30 commit 9df8791

7 files changed

Lines changed: 329 additions & 6 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -299,6 +299,22 @@ because it turns other people's test suites red.
299299

300300
### Fixed
301301

302+
- **The window now warns when a run's record will be too big for this build to
303+
read back.** The command line has said this since the ceiling was measured.
304+
The window said nothing at all, so somebody who generated 25 000 files from it
305+
was left with a directory that `tfg verify` and `tfg cleanup` both refuse -
306+
and the manifest is the only authority over what may be removed, so those
307+
files could never be cleaned up by this tool again.
308+
309+
It appears in two places, because the window cannot speak in the middle of a
310+
run: under Preview, which is the window's answer to `--dry-run`, and again
311+
when a run finishes. The second is the one that matters, since Preview is a
312+
button somebody may never press. It comes straight after the line saying what
313+
the run did, ahead of any other note.
314+
315+
The run itself still works and is still not refused. What was missing was that
316+
nobody was told.
317+
302318
- **`tfg verify` no longer calls another run's files "extra".** A directory is
303319
allowed to hold more than one run - that is what `output.manifest` is for -
304320
and verifying one of them reported every file the other had written as a file
Lines changed: 222 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,222 @@
1+
package guard
2+
3+
import (
4+
"strings"
5+
"testing"
6+
7+
"github.com/donislawdev/TestingFilesGenerator/internal/gui/parts"
8+
"github.com/donislawdev/TestingFilesGenerator/internal/gui/text"
9+
"github.com/donislawdev/TestingFilesGenerator/internal/manifest"
10+
)
11+
12+
// The window says the same thing the command line says about a record too big
13+
// to read back.
14+
//
15+
// Observation O184, measured on 2026-09-06: TooLargeToReadBack had two callers,
16+
// both in internal/cli and internal/manifest, and NOT ONE in internal/gui. A
17+
// person who generated 25 000 files from the window was told nothing at all,
18+
// and was left with a directory that tfg verify and tfg cleanup both refuse -
19+
// the manifest is the only authority over what may be removed, so a manifest
20+
// that cannot be read is a set of files with no owner.
21+
//
22+
// It is the kind of parity gap D1 loses most easily. Not something the engine
23+
// can do from one surface and not the other, which is what the parity guard
24+
// looks for, but something one surface SAYS and the other does not.
25+
//
26+
// The note was written down as a question about the manifest schema, on the
27+
// grounds that notes are per file and this one is per run. It is not. The
28+
// command line does not read this off the manifest either - it works it out
29+
// from the plan and prints it before the first byte - and manifest.TooLarge-
30+
// ToReadBack was put where it is exactly so the two surfaces could not come to
31+
// different conclusions about one run. What was missing was a caller.
32+
33+
// overTheCeiling is a file count whose manifest this build would refuse.
34+
//
35+
// Worked out from the estimate rather than written here, for the reason the
36+
// command line guard beside it gives: a guard carrying its own copy of a limit
37+
// goes stale the day somebody changes the real one, and says nothing while it
38+
// does.
39+
func overTheCeiling(t *testing.T) int {
40+
t.Helper()
41+
over := int(manifest.MaxBytes/manifest.BytesPerEntry) + 1000
42+
if _, tooBig := manifest.TooLargeToReadBack(over, 0); !tooBig {
43+
t.Fatalf("%d entries was not judged too large, so this guard would prove nothing", over)
44+
}
45+
return over
46+
}
47+
48+
// previewOf presses Preview for a run of count files and gives back what the
49+
// screen said.
50+
//
51+
// It REFUSES to return a refusal, and that is the whole reason it exists. The
52+
// first version of the pair below set a size the default format will not take -
53+
// the window opens on the first format in the registry, which is avif, and
54+
// 200 B is far under what a picture needs. Both previews were turned down, so
55+
// the negative half passed while proving nothing: a screen that says "check the
56+
// settings marked above" says nothing about a manifest ceiling either.
57+
func previewOf(t *testing.T, count string) string {
58+
t.Helper()
59+
content, w, host := screenInAWindowWithHost(t, text.TabOneTarget())
60+
61+
// txt rather than whatever the window opens on, for two reasons. The size
62+
// below has to be one the format takes, and planning twenty two thousand
63+
// pictures would encode twenty two thousand pictures - png, jpg, gif and
64+
// avif all do that while planning.
65+
picker, ok := controlUnder(content, text.FieldFormat()).(*parts.Chooser)
66+
if !ok {
67+
t.Fatal("the format field is not a list to choose from, so this guard read the wrong tree")
68+
}
69+
picker.SetSelected("txt")
70+
71+
// Small files, because what is being asked about is the number of ENTRIES
72+
// rather than the number of bytes. A preview writes nothing either way.
73+
fill(t, content, text.FieldSize(), "200b")
74+
fill(t, content, text.FieldCount(), count)
75+
76+
press(t, content, text.ButtonPreview())
77+
// This preview is accepted, so it answers from a worker. Joined before the
78+
// status line is read - see join.
79+
join(host)
80+
settle(content, w)
81+
82+
_, status := runMessages(content)
83+
if status == nil {
84+
t.Fatal("the screen has no status line, so this guard read the wrong tree")
85+
}
86+
// Matched on the tail of the preview's own sentence, the way the action bar
87+
// guard does it, so this cannot be satisfied by a refusal.
88+
marker := text.PreviewCost(1, nil, "1 B")
89+
tail := marker[strings.LastIndex(marker, " ")+1:]
90+
if !strings.Contains(status.Text, tail) {
91+
t.Fatalf("the preview of %s files was not accepted, so nothing here was asked about the manifest.\nIt said:\n%s",
92+
count, status.Text)
93+
}
94+
return status.Text
95+
}
96+
97+
// runOf presses Generate rather than Preview, and gives back what the screen
98+
// said when it finished.
99+
//
100+
// It exists because the preview is OPTIONAL. Somebody who presses Generate
101+
// straight away never sees the preview's answer, and that person is exactly the
102+
// one observation O184 is about - they end up with a directory nothing in this
103+
// toolset can read or clean. The window cannot say anything in the middle of a
104+
// run, so the end of the run is the only place left.
105+
//
106+
// It writes files, which is why this is the one guard here that does. Twenty
107+
// two thousand of them at 200 B, into a directory that goes away with the test.
108+
func runOf(t *testing.T, count string) string {
109+
t.Helper()
110+
content, w, host := screenInAWindowWithHost(t, text.TabOneTarget())
111+
112+
picker, ok := controlUnder(content, text.FieldFormat()).(*parts.Chooser)
113+
if !ok {
114+
t.Fatal("the format field is not a list to choose from, so this guard read the wrong tree")
115+
}
116+
picker.SetSelected("txt")
117+
fill(t, content, text.FieldSize(), "200b")
118+
fill(t, content, text.FieldCount(), count)
119+
fill(t, content, text.FieldOutputDir(), t.TempDir())
120+
121+
press(t, content, text.ButtonGenerate())
122+
join(host)
123+
settle(content, w)
124+
125+
_, status := runMessages(content)
126+
if status == nil {
127+
t.Fatal("the screen has no status line, so this guard read the wrong tree")
128+
}
129+
// The run has to have HAPPENED. A refused run says nothing about a
130+
// manifest either, and a guard that cannot tell those apart is the shape
131+
// this project has recorded as passing without reaching the code.
132+
if !strings.Contains(status.Text, text.Written(0)[strings.LastIndex(text.Written(0), " ")+1:]) {
133+
t.Fatalf("the run of %s files did not finish, so nothing here was asked about the manifest.\nIt said:\n%s",
134+
count, status.Text)
135+
}
136+
return status.Text
137+
}
138+
139+
// warningAbout is the fixed half of the sentence, without the two numbers.
140+
//
141+
// Taken from the text package rather than typed here, so a reworded warning
142+
// does not quietly stop being checked.
143+
func warningAbout(t *testing.T) string {
144+
t.Helper()
145+
marker := text.ManifestTooLargeToRead("SIZE", "LIMIT")
146+
at := strings.Index(marker, "SIZE")
147+
if at < 0 {
148+
t.Fatal("the warning does not carry the size it was given, so this guard cannot find its fixed half")
149+
}
150+
return marker[:at]
151+
}
152+
153+
func TestTheWindowSaysWhenItsManifestWillBeTooBigToReadBack(t *testing.T) {
154+
said := previewOf(t, itoa(overTheCeiling(t)))
155+
if !strings.Contains(said, warningAbout(t)) {
156+
t.Errorf("a preview of %d files said nothing about the record being too big to read back.\n"+
157+
"The command line has said this since 2026-08-26. Somebody who does the same from the window "+
158+
"gets a directory that neither Verify nor Clean up can read, and no warning.\nIt said:\n%s",
159+
overTheCeiling(t), said)
160+
}
161+
}
162+
163+
// And a run that was never previewed says it too, which is the case that
164+
// matters most.
165+
//
166+
// The preview is a button somebody may not press. The warning has to reach the
167+
// person who pressed Generate and nothing else, because they are the one left
168+
// with the directory.
169+
func TestAFinishedRunInTheWindowSaysItsManifestIsTooBigToReadBack(t *testing.T) {
170+
said := runOf(t, itoa(overTheCeiling(t)))
171+
if !strings.Contains(said, warningAbout(t)) {
172+
t.Errorf("a finished run of %d files said nothing about the record being too big to read back.\n"+
173+
"That directory now has a manifest neither Verify nor Clean up can read, and nobody was told.\nIt said:\n%s",
174+
overTheCeiling(t), said)
175+
}
176+
// The line somebody pressed the button for stays first. The room for these
177+
// messages is a ceiling and the message scrolls inside it.
178+
if first := strings.SplitN(said, "\n", 2)[0]; strings.Contains(first, warningAbout(t)) {
179+
t.Errorf("the warning took the first line from the outcome:\n%s", said)
180+
}
181+
}
182+
183+
// A run this build CAN read back stays quiet.
184+
//
185+
// Without this the guard above passes on a window that warns about every run,
186+
// which teaches somebody to stop reading the line - the same reason the command
187+
// line has this pair rather than only the first half.
188+
func TestAnOrdinaryPreviewSaysNothingAboutTheManifestCeiling(t *testing.T) {
189+
said := previewOf(t, "100")
190+
if strings.Contains(said, warningAbout(t)) {
191+
t.Errorf("a hundred files drew the warning about the record being too big:\n%s", said)
192+
}
193+
}
194+
195+
// Both surfaces judge the same run the same way.
196+
//
197+
// The window reads the answer off the document a dry run builds, and the
198+
// command line works it out from the plan before anything is written. Two paths
199+
// to one number, and what makes two paths acceptable is that they go through
200+
// one predicate. Asked at the boundary, which is the only place a disagreement
201+
// would show.
202+
func TestBothSurfacesJudgeTheSameRunTheSameWay(t *testing.T) {
203+
for _, entries := range []int{
204+
int(manifest.MaxBytes / manifest.BytesPerEntry),
205+
int(manifest.MaxBytes/manifest.BytesPerEntry) + 1,
206+
} {
207+
_, fromThePlan := manifest.TooLargeToReadBack(entries, 0)
208+
209+
m := manifest.New("testing-files-generator", "0.0.0-dev", "run_x", "tfg generate", 1, "linux", "amd64")
210+
for i := 0; i < entries; i++ {
211+
m.Add(manifest.File{Path: "f.txt", Materialized: true})
212+
}
213+
_, fromTheDocument := m.ReadBackReach()
214+
215+
if fromThePlan != fromTheDocument {
216+
t.Errorf("at %d entries the plan says %v and the document says %v.\n"+
217+
"The command line answers from the first and the window from the second, so one run "+
218+
"would be warned about on one surface and not on the other",
219+
entries, fromThePlan, fromTheDocument)
220+
}
221+
}
222+
}

‎internal/gui/text/locale/en.json‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -248,6 +248,10 @@
248248
"description": "Shown in the window. Carries one value, {{.Path}}, which has to stay spelled exactly that way.",
249249
"other": "the files were written and the manifest could not be saved to {{.Path}}"
250250
},
251+
"ManifestTooLargeToRead": {
252+
"description": "Shown in the window. Carries these values, each of which has to stay spelled exactly that way: {{.Size}}, {{.Limit}}.",
253+
"other": "this run's record is about {{.Size}} and this build reads at most {{.Limit}}, so Verify and Clean up will not be able to read it. Split the run to keep each record readable."
254+
},
251255
"NotAWholeNumber": {
252256
"description": "Shown in the window. Carries these values, each of which has to stay spelled exactly that way: {{.Field}}, {{.Value}}.",
253257
"other": "{{.Field}} is {{.Value}}, which is not a whole number. Write the digits out, such as 1 or 500"

‎internal/gui/text/text.go‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,23 @@ func PreviewCost(count int, formats []string, total string) string {
9696
say("PreviewNothingWritten", "nothing written yet")
9797
}
9898

99+
// ManifestTooLargeToRead is said when a run will write a record this build
100+
// cannot read back.
101+
//
102+
// The command line has printed this since 2026-08-26 and the window said
103+
// nothing at all, which is the parity gap observation O184 names. A person who
104+
// generates 25 000 files from a window gets a directory that tfg verify and
105+
// tfg cleanup both refuse - and the manifest is the only authority over what
106+
// may be deleted, so nothing in this toolset can ever remove those files.
107+
//
108+
// A note rather than a refusal, which is the owner's decision from that day and
109+
// is unchanged here. The run works. What was missing was that nobody was told.
110+
func ManifestTooLargeToRead(size, limit string) string {
111+
return sayf("ManifestTooLargeToRead",
112+
"this run's record is about {{.Size}} and this build reads at most {{.Limit}}, so Verify and Clean up will not be able to read it. Split the run to keep each record readable.",
113+
map[string]any{"Size": size, "Limit": limit})
114+
}
115+
99116
// PreviewFreeSpace follows PreviewCost when the disk could be measured. It is
100117
// a separate fact because a disk we cannot read has to say nothing at all
101118
// rather than invent a number.

‎internal/gui/window/run.go‎

Lines changed: 18 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -664,25 +664,30 @@ func (r *runner) onPreview() {
664664
// Do rather than DoAndWait, for the same reason startRun gives: the
665665
// interface thread must never be left waiting on a worker.
666666
r.holdBeforeFinishing()
667-
fyne.Do(func() { r.previewFinished(nil, opt, planErr) })
667+
fyne.Do(func() { r.previewFinished(nil, nil, opt, planErr) })
668668
close(done)
669669
return
670670
}
671-
_, runErr := engine.Run(ctx, planned, opt)
671+
res, runErr := engine.Run(ctx, planned, opt)
672672
r.holdBeforeFinishing()
673-
fyne.Do(func() { r.previewFinished(planned, opt, runErr) })
673+
fyne.Do(func() { r.previewFinished(res, planned, opt, runErr) })
674674
close(done)
675675
}()
676676
}
677677

678678
// previewFinished is the end of a preview, back on the interface thread.
679-
func (r *runner) previewFinished(planned []engine.PlannedFile, opt engine.Options, runErr error) {
679+
//
680+
// The result is carried across as well as the plan, and that is what lets a
681+
// preview warn about a record too big to read back. A dry run builds the whole
682+
// document - see manifestReachNote - so the answer is there for the asking
683+
// rather than something the window would have to work out for itself.
684+
func (r *runner) previewFinished(res *engine.Result, planned []engine.PlannedFile, opt engine.Options, runErr error) {
680685
r.setBusy(false, false)
681686
if runErr != nil {
682687
r.refuse(runErr)
683688
return
684689
}
685-
r.say(previewText(planned, opt.OutDir))
690+
r.say(append([]string{previewText(planned, opt.OutDir)}, manifestReachNote(res)...)...)
686691
}
687692

688693
// formatsOf is what kinds of file the run would produce, each named once.
@@ -837,7 +842,14 @@ func (r *runner) runFinished(res *engine.Result, runErr, saveErr error) {
837842
// and not only in the manifest - "the manifest says which ones" is an
838843
// answer in a terminal and an instruction to open a file with ten thousand
839844
// entries in a window.
840-
r.say(append([]string{outcomeText(res, runErr)}, notesOf(res)...)...)
845+
//
846+
// The warning about a record too big to read back comes SECOND, ahead of
847+
// the per file notes, and that order is the same lesson the command line
848+
// learned on 2026-09-06: it is the one line standing between somebody and a
849+
// directory nothing in this toolset can ever clean up, and it was being
850+
// buried under notes about a label that did not fit.
851+
said := append([]string{outcomeText(res, runErr)}, manifestReachNote(res)...)
852+
r.say(append(said, notesOf(res)...)...)
841853
r.toneOfOutcome(res, runErr)
842854
r.offerTheFolder(res)
843855
}

‎internal/gui/window/runreport.go‎

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import (
77
"github.com/donislawdev/TestingFilesGenerator/internal/core"
88
"github.com/donislawdev/TestingFilesGenerator/internal/engine"
99
"github.com/donislawdev/TestingFilesGenerator/internal/gui/text"
10+
"github.com/donislawdev/TestingFilesGenerator/internal/manifest"
1011
)
1112

1213
// What a run tells the person while it goes and when it ends.
@@ -31,6 +32,37 @@ func previewText(planned []engine.PlannedFile, outDir string) string {
3132
return line
3233
}
3334

35+
// manifestReachNote is the window's half of the warning the command line prints
36+
// before the first byte.
37+
//
38+
// Observation O184: the command line has said this since 2026-08-26 and the
39+
// window said nothing at all, so a person generating 25 000 files from a window
40+
// was left with a directory that neither Verify nor Clean up can read - and the
41+
// manifest is the only authority over what may be removed. A parity gap in
42+
// quality rather than in what the engine can do, which is the kind D1 is
43+
// easiest to lose.
44+
//
45+
// Read off the document rather than worked out here, and off the SAME predicate
46+
// the command line uses, which is what manifest.TooLargeToReadBack exists for.
47+
// The two surfaces cannot come to different conclusions about one run.
48+
//
49+
// A preview reaches this too. engine.Run with DryRun adds an entry for every
50+
// planned file, so the document a preview produces is the document the run
51+
// would produce, minus the bytes on the disk. That is why one shape serves
52+
// both, and why the window can answer before anything is written even though it
53+
// cannot say a word in the middle of a run.
54+
func manifestReachNote(res *engine.Result) []string {
55+
if res == nil || res.Manifest == nil {
56+
return nil
57+
}
58+
size, over := res.Manifest.ReadBackReach()
59+
if !over {
60+
return nil
61+
}
62+
return []string{text.ManifestTooLargeToRead(
63+
core.HumanBytes(size), core.HumanBytes(manifest.MaxBytes))}
64+
}
65+
3466
// progressText is the line under the bar. Bytes rather than files, because one
3567
// large file is a run where the file count says nothing for minutes.
3668
func progressText(p engine.Progress, elapsed time.Duration) string {

0 commit comments

Comments
 (0)