Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion .github/race-weights
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,6 @@ unlisted 0.58
35.5 TestJxlStillFitsWhenTheQualityIsNotTheOneTheCeilingsWereMeasuredAt
30.8 TestOurOwnGeneratorsHaveNotDrifted
29.9 TestTheSameSeedGivesTheSameBytes
27.7 TestTheCeilingCountsTheWholeRunNotOneTarget
26.8 TestEveryJxlRungStillFitsInsideItsDeclaredCeiling
26.6 TestTheMinimumStandsWhateverTheSeedIsUnderEveryDeclaredChoice
24.9 TestEveryEMLSettingSurvivesItsReferenceTool
Expand Down
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,9 +49,21 @@ because it turns other people's test suites red.
`--software-gl`, moves from 26.2.0 to 26.2.4, built with LLVM 23.1.2. Its
licences are the same. `THIRD-PARTY-NOTICES.md` names the new files and
their sums.
- **A recipe past the ceiling of a million files ends with code 3 even when a
target also asks for a size its format cannot make.** That size used to be
refused first, with code 4, and the ceiling came out only once it was
fixed. The ceiling is now found while the recipe is read, and like every
problem found there it comes first.

### Fixed

- **A recipe asking for more than a million files in all is refused before
it takes the memory.** Targets were held to a million each and the run only
once all their sizes were laid out, so `tfg validate` took 2.5 GB to refuse
fifty targets of a million, and the recipe screen spent 40 MB a key on each
such batch. The total is now counted as the recipe is read, beside its other
problems, and `validate --json` places it at the file's own target in a
recipe built on a preset. Reading takes a fifth of the memory.
- **Cancel answers at once, and closing the window during a run no longer
freezes it.** Both used to wait for the write in progress with the window
standing still, which on a slow or network disk could last long enough for
Expand Down
16 changes: 16 additions & 0 deletions internal/core/limits.go
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,22 @@ var (
"Or give the run more memory through the memory setting")
)

// TooManyFilesInRun is what a run past MaxFilesPerRun asks for: every file of
// every target, across all of them.
//
// Both the recipe reader and the engine count the run, and a recipe refused by
// either reads the same - so the sentence is written here, once, rather than
// beside each of them (O302). The number is the whole run's, not the running
// total at the target that crossed the ceiling where both of them refuse: ten
// targets of a million were told they asked for two million (review of #219).
// An int64 because a recipe of a megabyte holds tens of thousands of targets
// of a million, which is past what an int of 32 bits counts.
func TooManyFilesInRun(files int64, targets int) Said {
return Says("core.TooManyFilesInRun", "this run asks for %s across %s",
A("Files", SaysN("core.FilesInRun", "%d file", "%d files", A("Count", files))),
A("Targets", SaysN("core.TargetsInRun", "%d target", "%d targets", A("Count", targets))))
}

// PlanTooLargeWhy is the same refusal for the quantity that actually runs out,
// in the words of where its ceiling came from.
//
Expand Down
20 changes: 20 additions & 0 deletions internal/core/size.go
Original file line number Diff line number Diff line change
Expand Up @@ -332,3 +332,23 @@ func BoundarySizes(limit int64) ([]int64, error) {
}
return []int64{limit - 1, limit, limit + 1}, nil
}

// Uniform is n files of the same size, in a list reserved at its length.
//
// Here because the recipe reader builds the same list as the engine and cannot
// import it, and two copies of one list were what a review of #219 named.
// Reserved rather than grown: one append at a time, the list for a million
// files cost 40 B a file to build for 8 B a file kept - every growth leaves the
// last array to the collector - and the batch screen builds it on every key
// (O302, measured on 2026-11-04: 41.7 MB for a target of a million, 8.0 MB
// reserved).
func Uniform(n int, bytes int64) []int64 {
if n <= 0 {
return nil
}
out := make([]int64, n)
for i := range out {
out[i] = bytes
}
return out
}
43 changes: 25 additions & 18 deletions internal/engine/engine.go
Original file line number Diff line number Diff line change
Expand Up @@ -100,17 +100,9 @@ type SizeRange struct {
Min, Max int64
}

// Uniform is n files of the same size, which is what most targets ask for.
func Uniform(n int, bytes int64) []int64 {
if n <= 0 {
return nil
}
out := make([]int64, n)
for i := range out {
out[i] = bytes
}
return out
}
// Uniform is n files of the same size, which is what most targets ask for. It
// is core's, under the name the engine's callers reach for.
func Uniform(n int, bytes int64) []int64 { return core.Uniform(n, bytes) }

// Options are the settings of one run.
type Options struct {
Expand Down Expand Up @@ -474,15 +466,19 @@ func PlanContext(ctx context.Context, targets []Target, opt Options) ([]PlannedF
// so - but a refusal a window cannot place is a refusal at the
// foot of a form with twenty batches above it and nothing marked.
//
// Reachable only from here, and measured on 2026-08-25: the recipe
// reader bounds each target on its own, so three batches of four
// hundred thousand pass it and the total is refused here. Before
// this line "validate --json" carried no "at" for it.
// No surface reaches this any more. The recipe reader counts the
// same total since O302 and refuses first, in the same words, so
// a recipe never arrives here past the ceiling - and the flag
// path and the Generate screen are one target, bounded on its
// own. It stays because Plan is
// handed targets by whoever calls it, and planCapacity reserves
// nothing past the ceiling on the word of this refusal. Its
// position counts the merged list, which for a recipe built on a
// preset is not the file's (measured on 2026-11-04) - the
// reader's is.
return nil, &RecipeError{
Setting: core.TargetAddress(i+1, SettingCount),
Detail: core.Says("engine.TooManyFiles", "this run asks for %s across %s",
core.A("Files", core.SaysN("engine.Files", "%d file", "%d files", core.A("Count", totalFiles))),
core.A("Targets", core.SaysN("engine.Targets", "%d target", "%d targets", core.A("Count", len(targets))))),
Detail: core.TooManyFilesInRun(filesIn(targets), len(targets)),
Because: core.TooManyFilesWhy,
Remedy: core.TooManyFilesFix}
}
Expand All @@ -494,6 +490,17 @@ func PlanContext(ctx context.Context, targets []Target, opt Options) ([]PlannedF
return pl.out, nil
}

// filesIn is every file the targets ask for, which is what a refusal of the
// run's ceiling says - rather than the running total at the target that
// crossed it, which is where the refusal is made.
func filesIn(targets []Target) int64 {
var n int64
for i := range targets {
n += int64(len(targets[i].Sizes))
}
return n
}

// Run writes a planned set of files.
//
// Each file is written under a temporary name and only then renamed, so the
Expand Down
105 changes: 100 additions & 5 deletions internal/guard/mutation/entries.py
Original file line number Diff line number Diff line change
Expand Up @@ -2542,16 +2542,18 @@
),
(
# The recipe reader took the worse of the two failures: it grew the list
# one entry at a time and reached a 13 GB allocation.
# one entry at a time and reached a 13 GB allocation. Since O302 the
# mutated count reaches no allocation - the run's ceiling turns the same
# recipe away and builds no list - so the guard asks which refusal it got.
"the recipe reader stops asking whether the count can be planned",
"internal/recipe/target.go",
# Carries the line under it since 2026-08-20. The container entry gained
# the same ceiling that day, so this pattern stopped being unique - and
# an ambiguous pattern is how a mutation quietly starts proving a
# different guard than the one it names. The two are told apart by which
# address they build: `where` for the target, `at` for the entry.
"case n > core.MaxFilesPerRun:\n\t\t\tp.add(where.of(\"count\")",
"case n > core.MaxFilesPerRun && false:\n\t\t\tp.add(where.of(\"count\")",
"case n > core.MaxFilesPerRun:\n\t\tp.add(where.of(\"count\")",
"case n > core.MaxFilesPerRun && false:\n\t\tp.add(where.of(\"count\")",
"TestAnImpossibleFileCountInARecipeIsRefused",
),
(
Expand All @@ -2568,12 +2570,102 @@
"TestAnImpossibleFileCountInsideAContainerIsRefused",
),
(
# Asked of the engine directly since O302. The recipe reader counts the
# run first, so the guard asking validate about pieces never reached
# this line again.
"the ceiling is applied per target, so writing the number out in pieces gets past it",
"internal/engine/engine.go",
"totalFiles += len(t.Sizes)",
"totalFiles = len(t.Sizes)",
"TestTheEngineCountsTheWholeRunOfTargetsHandedToItDirectly",
),
(
# The same break on the reader's side (O302): the targets before this
# one are forgotten, so twelve of 90 000 pass one at a time.
"the recipe reader counts each target against the run's ceiling alone",
"internal/recipe/recipe.go",
"planned = min(planned+files, core.MaxFilesPerRun+1)",
"planned = min(files, core.MaxFilesPerRun+1)",
"TestTheCeilingCountsTheWholeRunNotOneTarget",
),
(
# Asked beside another problem of the recipe, so the engine never runs
# and cannot stand in for the reader - which it does for every guard
# asking validate about the ceiling alone.
"the recipe reader stops refusing a run past the ceiling",
"internal/recipe/recipe.go",
"if room >= 0 && files > room {",
"if false && room >= 0 && files > room {",
"TestARunPastTheCeilingIsReportedBesideTheRecipesOtherProblems",
),
(
"the run's ceiling refuses a run of exactly a million",
"internal/recipe/recipe.go",
"if room >= 0 && files > room {",
"if room >= 0 && files >= room {",
"TestTheRunCeilingSitsExactlyAtAMillion",
),
(
# The engine's address: the merged list, preset's targets first.
"the run's ceiling names the target by the merged list rather than the file",
"internal/recipe/recipe.go",
"crossedAt = at(t.ID).of(KeyCount)",
"crossedAt = targetSpot(i, t.ID).of(KeyCount)",
"TestTheTargetThatCrossesTheRunCeilingIsPlacedInTheFile",
),
(
"the run's ceiling marks the size of the batch that crossed it rather than its count",
"internal/recipe/recipe.go",
"crossedAt = at(t.ID).of(KeyCount)",
"crossedAt = at(t.ID).of(KeySize)",
"TestARefusalAboutTheWholeRunMarksTheBatchThatCausedIt",
),
(
# Review of #219: the running total where the run crossed is what both
# layers said, and ten targets of a million were told two million.
"the run's ceiling says the files up to the target that crossed it rather than the whole run's",
"internal/recipe/recipe.go",
"p.add(crossedAt, core.TooManyFilesInRun(asked, len(raw.Targets)),",
"p.add(crossedAt, core.TooManyFilesInRun(int64(planned), len(raw.Targets)),",
"TestTheRunCeilingSaysWhatTheWholeRunAsksFor",
),
(
"the engine's ceiling says the files up to the target that crossed it rather than the whole run's",
"internal/engine/engine.go",
"Detail: core.TooManyFilesInRun(filesIn(targets), len(targets)),",
"Detail: core.TooManyFilesInRun(int64(totalFiles), len(targets)),",
"TestTheEngineCountsTheWholeRunOfTargetsHandedToItDirectly",
),
(
"a target past the run's ceiling has its list built before it is refused",
"internal/recipe/target.go",
"\tif count > room {\n\t\tbuilt = 0\n\t}",
"\tif false {\n\t\tbuilt = 0\n\t}",
"TestATargetPastTheRunCeilingIsNotBuilt",
),
(
# In core since the review of #219, which named the reader's own copy of
# engine.Uniform. Both now take this one.
"a target's list of sizes is grown one file at a time again",
"internal/core/size.go",
"\tout := make([]int64, n)\n\tfor i := range out {\n\t\tout[i] = bytes\n\t}",
"\tvar out []int64\n\tfor range n {\n\t\tout = append(out, bytes)\n\t}",
"TestATargetsListOfSizesIsReservedAtItsLength",
),
(
"a boundary set counts as the one file of the default count against the run's ceiling",
"internal/recipe/target.go",
"\tif rt.Boundary != nil {\n\t\tfiles = len(t.Sizes)\n\t}",
"\tif false {\n\t\tfiles = len(t.Sizes)\n\t}",
"TestTheRunCeilingSitsExactlyAtAMillion",
),
(
"a count refused on its own goes back to asking for the default",
"internal/recipe/target.go",
"\t\treturn int(n)\n\t}\n\treturn 0\n}",
"\t\treturn int(n)\n\t}\n\treturn DefaultCount\n}",
"TestTheRunCeilingSitsExactlyAtAMillion",
),
(
# The other direction. A ceiling low enough to refuse ordinary work
# would satisfy every guard above and break the tool for everybody.
Expand Down Expand Up @@ -5391,18 +5483,21 @@
"TestTheChangelogHasOneHeadingPerKindOfChange",
),
(
# Both asked of the engine directly since O302 - the window's batch
# screen reaches the reader's refusal now, and the guard over it moved
# with it (TestARefusalAboutTheWholeRunMarksTheBatchThatCausedIt).
"the ceiling refusal stops naming the batch that caused it",
"internal/engine/engine.go",
"\t\t\t\tSetting: core.TargetAddress(i+1, SettingCount),",
'\t\t\t\tSetting: "",',
"TestARefusalFromTheEngineAboutTheWholeRunMarksTheBatchThatCausedIt",
"TestTheEngineCountsTheWholeRunOfTargetsHandedToItDirectly",
),
(
"the ceiling refusal names the batch before the one that caused it",
"internal/engine/engine.go",
"\t\t\t\tSetting: core.TargetAddress(i+1, SettingCount),",
"\t\t\t\tSetting: core.TargetAddress(i, SettingCount),",
"TestARefusalFromTheEngineAboutTheWholeRunMarksTheBatchThatCausedIt",
"TestTheEngineCountsTheWholeRunOfTargetsHandedToItDirectly",
),
(
"the report stops carrying the address of a refusal from below the reader",
Expand Down
32 changes: 23 additions & 9 deletions internal/guard/numberlimits_test.go
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
package guard

import (
"path/filepath"
"strconv"
"strings"
"testing"

"github.com/donislawdev/TestingFilesGenerator/internal/cli"
"github.com/donislawdev/TestingFilesGenerator/internal/core"
"github.com/donislawdev/TestingFilesGenerator/internal/recipe"
)

// Two numbers a person can write that the tool could not honour, and used to
Expand Down Expand Up @@ -90,6 +90,14 @@ targets:
if strings.Contains(errOut, "fatal error") || strings.Contains(errOut, "out of memory") {
t.Errorf("the allocator was reached instead of the check:\n%s", errOut)
}
// Refused as the target's own count, not only as the run's. The run's
// ceiling turns this recipe away as well since O302, but it is the
// target's ceiling that keeps every term of the run's sum within a million
// - without it a count like this one, after the run is already past the
// ceiling, wraps the sum negative and every target after it is built again.
if !strings.Contains(errOut, "split the target across several recipes") {
t.Errorf("the count was not refused as the target's own:\n%s", errOut)
}
if stdout != "" {
t.Errorf("a failed run wrote to stdout:\n%s", stdout)
}
Expand Down Expand Up @@ -219,8 +227,13 @@ func TestABoundaryThatCannotHoldItsOwnSetIsRefused(t *testing.T) {
// A run spread across many targets reaches the same ceiling. Checking only the
// count of one target would leave the whole thing reachable by writing the
// number out in pieces.
//
// Asked of the recipe reader since O302, which counts the run before it builds
// a target's list. Through validate this was asked of the reader and of the
// engine under it at once, and a refusal from either passed - so either one
// could lose its count and leave this green, and the engine planned 990 000
// files to answer it (19 s, 28 s under the race detector).
func TestTheCeilingCountsTheWholeRunNotOneTarget(t *testing.T) {
dir := t.TempDir()
var b strings.Builder
b.WriteString("version: 1\ntargets:\n")
// Twelve targets of 90 000 files come to 1 080 000, which is over the
Expand All @@ -230,16 +243,17 @@ func TestTheCeilingCountsTheWholeRunNotOneTarget(t *testing.T) {
b.WriteString(string(rune('a' + i)))
b.WriteString("\n format: txt\n size: 1b\n count: 90000\n")
}
path := writeRecipe(t, dir, b.String())

code, stdout, errOut := run(t, "validate", path)
if code == cli.ExitOK {
t.Fatalf("a run of over a million files spread across targets was accepted:\n%s", stdout)
_, err := recipe.Parse([]byte(b.String()), "pieces")
if err == nil {
t.Fatal("a run of over a million files spread across targets was accepted")
}
if !strings.Contains(errOut, "1000000") {
t.Errorf("the refusal does not name the ceiling:\n%s", errOut)
said := err.Error()
for _, want := range []string{"1080000 files across 12 targets", "1000000"} {
if !strings.Contains(said, want) {
t.Errorf("the refusal does not say %q:\n%s", want, said)
}
}
_ = filepath.Join
}

// The refusal names the ceiling the rule really uses.
Expand Down
24 changes: 12 additions & 12 deletions internal/guard/recipescreen_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -518,20 +518,20 @@ func waitForNamedManifest(t *testing.T, settle func(), dir, name string) {
manifestAfter(t, settle, dir, name)
}

// A refusal from the ENGINE marks the batch it is about, the same as one from
// the recipe reader.
// A refusal about the whole run marks the batch that took it past the ceiling.
//
// The sibling above covers the reader. This one covers the layer under it, and
// they are not the same question: the reader bounds each batch on its own, so
// two batches of six hundred thousand files each pass it and the total is
// refused by the engine. Measured on 2026-08-25, before the address existed:
// the refusal arrived with a sentence and nothing else, so a screen with twenty
// batches said the run could not go and marked nothing at all.
// The sibling above covers a refusal about one batch's own value. This one is
// about a fact of the run, which no single box holds: two batches each within
// the count ceiling come to more than a run may plan. Measured on 2026-08-25,
// before the address existed: the refusal arrived with a sentence and nothing
// else, so a screen with twenty batches said the run could not go and marked
// nothing at all.
//
// The count is deliberately spread over two batches with the big one second.
// The engine tests the running total before planning a batch's files, so this
// reaches the refusal having planned one file rather than a million.
func TestARefusalFromTheEngineAboutTheWholeRunMarksTheBatchThatCausedIt(t *testing.T) {
// The engine refused this until O302, and the guard was named for it. The
// recipe reader counts the run now and refuses first, so this asks the reader -
// what the engine still says when it is handed targets directly is asked in
// runceiling_test.go.
func TestARefusalAboutTheWholeRunMarksTheBatchThatCausedIt(t *testing.T) {
screen := window.NewRecipe(newFakeHost(t))
body := screen.Object()
pressNamed(t, body, text.ButtonAddBatch())
Expand Down
Loading
Loading