diff --git a/CHANGELOG.md b/CHANGELOG.md index d818d758..9645e0b0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,14 @@ because it turns other people's test suites red. ### Added +- **How much memory a run may use is yours to set.** `--memory` on + `generate`, `validate` and `preset show`, and Memory on the window's + Preferences screen: `auto` (half of what this machine or container gives + the program), a size such as `8gb`, or `off`. Pictures and films wait for + room rather than all being held at once. A plan is refused at this + setting rather than at a fixed 2 GiB, and `auto` never goes below that. + Files come out byte for byte the same. + - **A Docker image.** `ghcr.io/donislawdev/tfg` holds the Linux command line of a release and nothing else, for x64 and Arm, and runs with no network. It is made from the published archive once its checksum and attestation check diff --git a/README.md b/README.md index cff2f7ab..4bed31dd 100644 --- a/README.md +++ b/README.md @@ -345,11 +345,12 @@ tfg generate --format txt --size 1mb settings come from the flags | `--clean` | turn off the self describing label written inside each file | | `--dry-run` | count and show, write nothing at all | | `--json` | write the manifest to standard output | +| `--memory ` | how much memory the run may use: `auto` (the default, half of what this machine gives the program), a size such as `8gb`, or `off` for no limit. Pictures wait for room rather than all being held at once, and a plan larger than this is refused before anything is written. Under `auto`, a plan of up to 2.0 GB is never refused. Also on `validate` and `preset show` | ### `tfg validate` ``` -tfg validate [--json] +tfg validate [--json] [--memory ] ``` Checks a recipe and writes nothing. Reports **every** problem at once rather diff --git a/internal/cli/generate.go b/internal/cli/generate.go index 8c76a9a1..d33d5729 100644 --- a/internal/cli/generate.go +++ b/internal/cli/generate.go @@ -113,6 +113,10 @@ Flags: func generate(ctx context.Context, args []string, out, errOut io.Writer) int { var g generateOpts fs, usage := generateFlagSet(errOut, &g) + // Beside the options rather than among them: it says nothing about the + // files, only about the machine, and generateOpts is at the crowding band. + var memory memoryValue + memoryFlag(fs, &memory) if helpRequested(args) { usage(out) return ExitOK @@ -156,6 +160,7 @@ func generate(ctx context.Context, args []string, out, errOut io.Writer) int { DryRun: g.dryRun, Command: "tfg " + strings.Join(args2(args), " "), ManifestName: defaultManifestName, + Memory: memory.Memory, } var ( @@ -446,9 +451,9 @@ func produce(ctx context.Context, targets []engine.Target, opt engine.Options, g bar.clear() } - for _, n := range res.Manifest.Notes() { - fmt.Fprintf(errOut, "note: %s\n", n) - } + // What the run says about the memory it ran in comes first: it is about + // the run rather than about any one file, and it is said once. + sayNotes(append(saidAll(res.RunNotes), res.Manifest.Notes()...), errOut) if runErr != nil { fmt.Fprintf(errOut, "tfg: %s%s\n", describeError(runErr), whatSurvived(runErr, res)) diff --git a/internal/cli/memoryflag.go b/internal/cli/memoryflag.go new file mode 100644 index 00000000..5e8f67f3 --- /dev/null +++ b/internal/cli/memoryflag.go @@ -0,0 +1,50 @@ +package cli + +import ( + "flag" + + "github.com/donislawdev/TestingFilesGenerator/internal/core" +) + +// --memory, on every command that plans: generate, validate and preset show. +// +// One function registers it on all three, so the flag cannot read one way on +// one command and another on the next, and core.ParseMemory reads it, so the +// flag and the box on the window's Preferences screen give the same answer to +// the same text - docs/STABILITY-REVIEW-2026-10-26.md section 10.8. +// +// The help is a fixed sentence with no number from this machine in it. The +// site prints the help of every command, and a number read off the machine +// that built the site would be a number about nobody's machine. The floor of +// the plan under auto is a number of the program rather than of the machine, +// so it is said, from the constant that sets it. + +// memoryHelp is what --help says about the flag. +var memoryHelp = "how much memory this run may use: auto (half of what this machine gives the program), " + + "a size such as 8gb, or off for no limit. Pictures wait for room rather than all being held at once, " + + "and a plan larger than this is refused before anything is written. " + + "Under auto, a plan of up to " + core.HumanBytes(core.MaxPlanBytes) + " is never refused" + +// memoryValue is the flag's value, auto until somebody writes one. +type memoryValue struct{ core.Memory } + +func (m *memoryValue) String() string { + if m == nil { + return core.Memory{}.String() + } + return m.Memory.String() +} + +func (m *memoryValue) Set(text string) error { + parsed, err := core.ParseMemory(text) + if err != nil { + return err + } + m.Memory = parsed + return nil +} + +// memoryFlag puts --memory on a command, its value landing in v. +func memoryFlag(fs *flag.FlagSet, v *memoryValue) { + fs.Var(v, "memory", memoryHelp) +} diff --git a/internal/cli/preset.go b/internal/cli/preset.go index 36db0b57..526948c0 100644 --- a/internal/cli/preset.go +++ b/internal/cli/preset.go @@ -66,7 +66,7 @@ type budget struct { Formats []string `json:"formats"` } -func budgetOf(ctx context.Context, e *preset.Expansion) (budget, error) { +func budgetOf(ctx context.Context, e *preset.Expansion, memory core.Memory) (budget, error) { rec, err := recipe.Parse(e.Source, e.Preset.ID) if err != nil { return budget{}, err @@ -77,7 +77,7 @@ func budgetOf(ctx context.Context, e *preset.Expansion) (budget, error) { targets = append(targets, engineTarget(t, t.Label)) seen[t.Format] = true } - planned, err := engine.PlanContext(ctx, targets, engine.Options{OutDir: rec.Output.Dir, Seed: rec.Seed}) + planned, err := engine.PlanContext(ctx, targets, engine.Options{OutDir: rec.Output.Dir, Seed: rec.Seed, Memory: memory}) if err != nil { return budget{}, err } @@ -399,3 +399,12 @@ func targetsFromPreset(fs *flag.FlagSet, g *generateOpts, given map[string]bool, opt.Preset = record(expanded) return targetsFromParsedRecipe(rec, hash, g, given, opt), ExitOK } + +// saidAll is sentences as the command line prints them. +func saidAll(said []core.Said) []string { + out := make([]string, 0, len(said)) + for _, s := range said { + out = append(out, s.String()) + } + return out +} diff --git a/internal/cli/presetcmd.go b/internal/cli/presetcmd.go index df5d51f9..7758528e 100644 --- a/internal/cli/presetcmd.go +++ b/internal/cli/presetcmd.go @@ -188,17 +188,25 @@ Usage: tfg preset show size-boundaries tfg preset show size-boundaries --limit 20mb --format png tfg preset show size-boundaries --json + +Flags, beside the preset's own parameters: + -json + write the answer as JSON to standard output + -memory value + `+memoryHelp+` (default auto) `) } var asJSON bool + var memory memoryValue expanded, code := presetFlagSet("show", args, out, errOut, usage, func(fs *flag.FlagSet) { fs.BoolVar(&asJSON, "json", false, "write the answer as JSON to standard output") + memoryFlag(fs, &memory) }) if expanded == nil { return code } - b, err := budgetOf(ctx, expanded) + b, err := budgetOf(ctx, expanded, memory.Memory) if err != nil { fmt.Fprintf(errOut, "tfg: %s\n", describeError(err)) return classify(err) diff --git a/internal/cli/recipecmd.go b/internal/cli/recipecmd.go index ae83e86c..7c6db763 100644 --- a/internal/cli/recipecmd.go +++ b/internal/cli/recipecmd.go @@ -46,12 +46,19 @@ func loadRecipe(path string, errOut io.Writer) (*preset.Read, string, int) { return read, hash, ExitOK } -// validate runs the checks a run would run and writes nothing at all, so it -// suits a pre commit hook. -func validate(ctx context.Context, args []string, out, errOut io.Writer) int { +// validateFlags is what validate is told besides the recipe. +type validateFlags struct { + asJSON bool + memory memoryValue +} + +// validateFlagSet is the flags of validate and its help, the way +// generateFlagSet is for generate. +func validateFlagSet(errOut io.Writer, v *validateFlags) (*flag.FlagSet, func(io.Writer)) { fs := flag.NewFlagSet("validate", flag.ContinueOnError) fs.SetOutput(errOut) - asJSON := fs.Bool("json", false, "write the result as JSON, with every problem separately") + fs.BoolVar(&v.asJSON, "json", false, "write the result as JSON, with every problem separately") + memoryFlag(fs, &v.memory) usage := func(w io.Writer) { fmt.Fprint(w, "tfg validate - check a recipe and write nothing.\n\nUsage:\n tfg validate \n\nFlags:\n") fs.SetOutput(w) @@ -59,6 +66,14 @@ func validate(ctx context.Context, args []string, out, errOut io.Writer) int { fs.SetOutput(errOut) } fs.Usage = func() { usage(errOut) } + return fs, usage +} + +// validate runs the checks a run would run and writes nothing at all, so it +// suits a pre commit hook. +func validate(ctx context.Context, args []string, out, errOut io.Writer) int { + var v validateFlags + fs, usage := validateFlagSet(errOut, &v) if helpRequested(args) { usage(out) return ExitOK @@ -77,7 +92,7 @@ func validate(ctx context.Context, args []string, out, errOut io.Writer) int { return ExitUsage } - read, hash, code := loadRecipeReporting(path, *asJSON, errOut) + read, hash, code := loadRecipeReporting(path, v.asJSON, errOut) if code != ExitOK { return code } @@ -92,9 +107,11 @@ func validate(ctx context.Context, args []string, out, errOut io.Writer) int { for _, t := range rec.Targets { targets = append(targets, engineTarget(t, t.Label)) } - planned, err := engine.PlanContext(ctx, targets, planningOptions(rec)) + opt := planningOptions(rec) + opt.Memory = v.memory.Memory + planned, err := engine.PlanContext(ctx, targets, opt) if err != nil { - return planningRefusal(err, path, *asJSON, errOut) + return planningRefusal(err, path, v.asJSON, errOut) } // A file that builds on a preset says so here the way the manifest will @@ -102,7 +119,7 @@ func validate(ctx context.Context, args []string, out, errOut io.Writer) int { // standard error, before the files exist as well as after. sayNotes(read.Notes(), errOut) - if *asJSON { + if v.asJSON { return writeJSON(out, errOut, validateReport{ Recipe: path, Valid: true, RecipeHash: hash, Targets: len(rec.Targets), Files: len(planned), diff --git a/internal/core/cgroup.go b/internal/core/cgroup.go new file mode 100644 index 00000000..787c57b4 --- /dev/null +++ b/internal/core/cgroup.go @@ -0,0 +1,289 @@ +package core + +import ( + "errors" + "io/fs" + "path" + "strconv" + "strings" +) + +// How a process on Linux finds the memory limit of the control group it runs +// in. Written as a function of the files rather than of the system, so a guard +// on any system can hand it the files of a container and check the answer - +// machinememory_linux.go is the only caller that hands it the real ones. +// +// The shape follows the reader in the Go runtime that finds the processor +// limit (internal/runtime/cgroup in Go 1.27.3, read 2026-10-27), because the +// hard part is the same: /proc/self/cgroup says which group, mountinfo says +// where that group is mounted, and in a container with its own cgroup +// namespace the two have to be put together carefully. +// +// Both versions are read from the group up to the top of its mount, because a +// parent's limit holds every group under it in both - version 1 accounts up the +// hierarchy too (docs.kernel.org cgroup-v1/memory, section 6, read 2026-10-28). +// +// What it does not read, and why. memory.high is a point where the kernel +// starts to reclaim rather than a limit it ends the process at. A parent above +// the top of the mount - the slice a container with its own namespace sits in - +// is not read in either version, because no path this process can see reaches +// it. Version 1 would name it in memory.stat (hierarchical_memory_limit), and +// version 2 has no file that does, so reading it for the old layout alone would +// make the two answer differently for the same container. + +// CgroupMemoryLimit is the memory limit of this process's control group, read +// through read from the paths Linux keeps the answer at. limited is false when +// there is no limit to find - no control group, no memory controller, "max" at +// every level - and then the memory of the machine is the answer. +// +// A file that is there and does not say what it should is an error rather than +// no limit. It is input from outside (untouchable rule 23), and a number made +// up from it would be a limit nobody set. +func CgroupMemoryLimit(read func(name string) ([]byte, error)) (limit int64, limited bool, err error) { + self, err := read("/proc/self/cgroup") + if errors.Is(err, fs.ErrNotExist) { + return 0, false, nil + } + if err != nil { + return 0, false, err + } + group, v1, err := memoryGroup(string(self)) + if err != nil || group == "" { + return 0, false, err + } + + mounts, err := read("/proc/self/mountinfo") + if err != nil { + return 0, false, err + } + mount, rel, err := memoryMount(string(mounts), v1, group) + if err != nil || mount == "" { + return 0, false, err + } + + if v1 { + return limitAbove(read, mount, rel, "memory.limit_in_bytes") + } + return limitAbove(read, mount, rel, "memory.max") +} + +// memoryGroup is the path of the group holding this process's memory, from +// /proc/self/cgroup, and whether it is a version 1 group. +// +// Each line is "hierarchy:controllers:path". Version 2 is hierarchy 0 with no +// controllers named. A version 1 line naming memory wins over it, because a +// controller can be mounted in one of the two and not both. +func memoryGroup(text string) (group string, v1 bool, err error) { + for _, line := range lines(text) { + hierarchy, controllers, where, err := groupLine(line) + if err != nil { + return "", false, err + } + switch { + case hierarchy == "0": + group = where + case hasWord(controllers, "memory"): + return where, true, nil + } + } + return group, false, nil +} + +// groupLine is one line of /proc/self/cgroup in its three parts. +func groupLine(line string) (hierarchy, controllers, where string, err error) { + hierarchy, rest, ok1 := strings.Cut(line, ":") + controllers, where, ok2 := strings.Cut(rest, ":") + // A group that was removed while this process was still in it is shown + // with this after its path. The limit is still the one that applies until + // the process moves, so it is read rather than refused. + where = strings.TrimSuffix(where, " (deleted)") + if !ok1 || !ok2 || !strings.HasPrefix(where, "/") { + return "", "", "", malformed("/proc/self/cgroup", line) + } + return hierarchy, controllers, where, nil +} + +// memoryMount is where the group's hierarchy is mounted, from +// /proc/self/mountinfo, and where the group is under that mount. +// +// The root matters in a container. Its own namespace shows its group as "/", +// and its mount's root as "/" too, so the group is the mount. Without a +// namespace the group is a path under a mount whose root is the hierarchy's +// root. A group shown as starting with "/.." is outside the namespace this +// process can see, and no mount reaches it. +// +// Any other "." or ".." in the group is refused rather than followed. Linux +// writes the path clean and gives no group either name, so one that is there +// is a file that does not say what Linux writes - and joined to the mount it +// would walk out of it and read a limit from wherever it lands. +func memoryMount(text string, v1 bool, group string) (mount, rel string, err error) { + for _, line := range lines(text) { + m, err := mountLine(line) + switch { + case err != nil: + return "", "", err + case !m.holdsMemory(v1): + continue + } + root, point, err := m.paths() + if err != nil { + return "", "", err + } + rel, reached := under(group, root) + switch { + case !reached || rel == "/.." || strings.HasPrefix(rel, "/../"): + continue + case rel != "" && path.Clean(rel) != rel: + return "", "", malformed("/proc/self/cgroup", group) + } + return point, rel, nil + } + return "", "", nil +} + +// mountEntry is the four fields of one line of mountinfo this reads: the root +// of the mount within its hierarchy and where it is mounted, both as Linux +// wrote them, the kind of filesystem and its own options. +type mountEntry struct { + root, point, kind, options string +} + +// mountLine splits one line of mountinfo. The optional fields in the middle +// are as many as the mount has, which is why the line is cut at the +// separator rather than counted. +func mountLine(line string) (mountEntry, error) { + before, after, ok := strings.Cut(line, " - ") + fields, tail := strings.Fields(before), strings.Fields(after) + if !ok || len(fields) < 5 || len(tail) < 1 { + return mountEntry{}, malformed("/proc/self/mountinfo", line) + } + m := mountEntry{root: fields[3], point: fields[4], kind: tail[0]} + if len(tail) >= 3 { + m.options = tail[2] + } + return m, nil +} + +// holdsMemory says whether this mount is the hierarchy the memory controller +// lives in: the one version 2 hierarchy, or the version 1 one naming memory. +func (m mountEntry) holdsMemory(v1 bool) bool { + if v1 { + return m.kind == "cgroup" && hasWord(m.options, "memory") + } + return m.kind == "cgroup2" +} + +// paths is the root and the mount point with the escapes undone. Asked only +// of the mount this reads through, so a line about some other filesystem is +// never refused for how its paths are written. +func (m mountEntry) paths() (root, point string, err error) { + if root, err = unescapeMount(m.root); err == nil { + point, err = unescapeMount(m.point) + } + return root, point, err +} + +// lines is the lines of a file that have anything on them. +func lines(text string) []string { + return strings.FieldsFunc(text, func(r rune) bool { return r == '\n' }) +} + +// under is where group is below root, when root reaches it at all. A root of +// "/" reaches every group, and any other reaches itself and what is under it - +// at a whole step of the path, so /sandbox does not reach /sandbox2. +func under(group, root string) (string, bool) { + if root == "/" { + return group, true + } + rest, ok := strings.CutPrefix(group, root) + if !ok || (rest != "" && !strings.HasPrefix(rest, "/")) { + return "", false + } + return rest, true +} + +// limitAbove is the smallest limit in file from the group up to the top of the +// mount - memory.max in version 2, memory.limit_in_bytes in version 1. A +// parent's limit holds every group under it, so a container whose own group +// says max can still be held by the slice it sits in. +func limitAbove(read func(string) ([]byte, error), mount, rel, file string) (int64, bool, error) { + var least int64 + var found bool + for dir := path.Join(mount, rel); ; dir = path.Dir(dir) { + n, ok, err := limitIn(read, path.Join(dir, file)) + if err != nil { + return 0, false, err + } + if ok && (!found || n < least) { + least, found = n, true + } + if dir == path.Clean(mount) || dir == "/" || dir == "." { + return least, found, nil + } + } +} + +// limitIn reads one limit file. Missing means no limit at that level - the +// root of a hierarchy has no memory.max at all. "max" is version 2 for none, +// and a number is the limit in bytes. +func limitIn(read func(string) ([]byte, error), name string) (int64, bool, error) { + b, err := read(name) + if errors.Is(err, fs.ErrNotExist) { + return 0, false, nil + } + if err != nil { + return 0, false, err + } + text := strings.TrimSpace(string(b)) + if text == "max" { + return 0, false, nil + } + n, err := strconv.ParseInt(text, 10, 64) + if err != nil || n < 0 { + return 0, false, malformed(name, text) + } + return n, true, nil +} + +// unescapeMount undoes what Linux does to a path in mountinfo: a space, a tab, +// a new line and a backslash are written as a backslash and three octal +// digits. +func unescapeMount(s string) (string, error) { + if !strings.Contains(s, `\`) { + return s, nil + } + var b strings.Builder + for i := 0; i < len(s); i++ { + if s[i] != '\\' { + b.WriteByte(s[i]) + continue + } + if i+4 > len(s) { + return "", malformed("/proc/self/mountinfo", s) + } + v, err := strconv.ParseUint(s[i+1:i+4], 8, 8) + if err != nil { + return "", malformed("/proc/self/mountinfo", s) + } + b.WriteByte(byte(v)) + i += 3 + } + return b.String(), nil +} + +// hasWord says whether a comma separated list holds one word. +func hasWord(list, word string) bool { + for _, w := range strings.Split(list, ",") { + if w == word { + return true + } + } + return false +} + +// malformed is the refusal for a file that does not say what it should. Said +// as a sentence because it reaches a person: it is the cause the note after a +// run gives for the memory it could not read. +func malformed(name, text string) error { + return Refuse(Says("core.CgroupMalformed", "%s does not say what Linux writes there: %q", A("File", name), A("Text", text))) +} diff --git a/internal/core/limits.go b/internal/core/limits.go index 7531b446..ff98bcae 100644 --- a/internal/core/limits.go +++ b/internal/core/limits.go @@ -55,33 +55,61 @@ var ( "that is what lets a run that cannot succeed be refused before the first byte", A("Most", MaxFilesPerRun)) TooManyFilesFix = Says("core.TooManyFilesFix", "Ask for fewer files, or split the work into several runs") - // The same refusal for the quantity that actually runs out. - // - // MaxFilesPerRun counts files and was justified by a measurement taken on - // txt. Re-measured on 2026-08-26 across formats, a planned file costs 850 B - // for txt, 6153 B for zip, 7527 B for pdf and 5244230 B for a pdf of a - // thousand pages - so the file ceiling lets a run ask for about 52 GB of - // plan while every number in it is legal. The sentence says memory rather - // than files because that is what the person has to change. - PlanTooLargeWhy = Says("core.PlanTooLargeWhy", - "the whole plan is held in memory before anything is written, and this build works to a ceiling of %s for it - "+ - "how much a file costs to plan depends on the format, so a ceiling on the number of files alone cannot see this", - A("Ceiling", Bytes(MaxPlanBytes))) - PlanTooLargeFix = Says("core.PlanTooLargeFix", "Ask for fewer files, or make each one cheaper to plan - fewer pages, fewer entries - or split the work into several runs") + // What to do about a plan past its ceiling. The memory setting is named + // by what it is rather than by the flag, because the window shows this + // sentence too and has no flags (O79) - both surfaces call it memory, the + // flag on one and the section of the Preferences screen on the other. + PlanTooLargeFix = Says("core.PlanTooLargeFix", + "Ask for fewer files, or make each one cheaper to plan - fewer pages, fewer entries - or split the work into several runs. "+ + "Or give the run more memory through the memory setting") ) -// MaxPlanBytes is the most memory this build will let a plan take. +// PlanTooLargeWhy is the same refusal for the quantity that actually runs out, +// in the words of where its ceiling came from. +// +// MaxFilesPerRun counts files and was justified by a measurement taken on +// txt. Re-measured on 2026-08-26 across formats, a planned file costs 850 B +// for txt, 6153 B for zip, 7527 B for pdf and 5244230 B for a pdf of a +// thousand pages - so the file ceiling lets a run ask for about 52 GB of plan +// while every number in it is legal. The sentence says memory rather than +// files because that is what the person has to change. +// +// Since 2026-10-27 the ceiling is the memory setting's rather than the build's +// (Memory.On), so the sentence says which of the three put it there. A number +// with no source is a number nobody knows how to move. +func PlanTooLargeWhy(m Memory, l MemoryLimits) Said { + switch { + case !m.IsAuto(): + return Says("core.PlanTooLargeWhySet", + "the whole plan is held in memory before anything is written, and the memory setting of this run gives it %s - "+ + "how much a file costs to plan depends on the format, so a ceiling on the number of files alone cannot see this", + A("Ceiling", Bytes(l.Plan))) + case l.Unknown != nil: + return Says("core.PlanTooLargeWhyUnknown", + "the whole plan is held in memory before anything is written, and this run gives it %s because the memory of this machine could not be read - "+ + "how much a file costs to plan depends on the format, so a ceiling on the number of files alone cannot see this", + A("Ceiling", Bytes(l.Plan))) + } + return Says("core.PlanTooLargeWhyAuto", + "the whole plan is held in memory before anything is written, and this run gives it %s, half of what this machine gives the program and never under %s - "+ + "how much a file costs to plan depends on the format, so a ceiling on the number of files alone cannot see this", + A("Ceiling", Bytes(l.Plan)), A("Floor", Bytes(MaxPlanBytes))) +} + +// MaxPlanBytes is the least memory auto lets a plan take, and the whole of it +// on a machine that cannot be asked. +// +// Until 2026-10-27 it was the ceiling for every run, on every machine, and +// nothing could move it - a person with sixty four gigabytes was refused at +// two. The memory setting took that over (Memory.On), and this stayed as the +// floor under auto, so that no run planned before the setting existed is +// refused after it. // // Two gigabytes, which is the figure the file ceiling beside it was always // meant to imply: its comment works out "a million files is therefore roughly -// 2.4 GB" and treats that as the real constraint. This makes the real -// constraint the one that is checked, so the two cannot disagree again. -// -// It is deliberately generous. Every run this tool was designed around is -// orders of magnitude under it - the largest preset asks for 10 040 files, -// which is about 9 MB of plan for txt and 76 MB for pdf - and the shapes it -// refuses are the ones that would otherwise end as an out of memory kill with -// no message at all. +// 2.4 GB" and treats that as the real constraint. Every run this tool was +// designed around is orders of magnitude under it - the largest preset asks +// for 10 040 files, which is about 9 MB of plan for txt and 76 MB for pdf. const MaxPlanBytes = 2 << 30 // PartialMarker is what a file being written is called before it is finished. diff --git a/internal/core/machinememory.go b/internal/core/machinememory.go new file mode 100644 index 00000000..33c48c81 --- /dev/null +++ b/internal/core/machinememory.go @@ -0,0 +1,32 @@ +package core + +import "math" + +// How much memory this machine gives this program, asked of the system once +// per run - MachineMemory, one file per system beside this one. +// +// The number is the setting auto halves (Memory.On), so it has to be the +// memory the process can really have rather than the memory in the box. On a +// laptop those are the same. In a container started with --memory 2g, on a CI +// runner, or in a job with a commit limit, the box has far more than the +// process may take, and a run that plans from the box is the one the system +// ends without a word - which is the failure the setting exists for. So every +// system's answer is the smallest of the numbers it offers: what is fitted, +// what the process is allowed to commit, and what any control group it is in +// holds it to. +// +// Only the standard library asks. golang.org/x/sys would offer these calls +// ready made, and it is not linked into the command line binary - putting it +// there is untouchable rule 11 rather than tidying, the same reason given in +// diskspace_windows.go. + +// smallest is the smallest of a machine's answers, as a count of bytes. None +// of them is ever above what an int64 holds on a real machine, and one that +// was is held to the largest rather than wrapped into a negative. +func smallest(answers ...uint64) int64 { + least := uint64(math.MaxInt64) + for _, a := range answers { + least = min(least, a) + } + return int64(least) +} diff --git a/internal/core/machinememory_darwin.go b/internal/core/machinememory_darwin.go new file mode 100644 index 00000000..c88d8ee7 --- /dev/null +++ b/internal/core/machinememory_darwin.go @@ -0,0 +1,41 @@ +package core + +import ( + "encoding/binary" + "fmt" + "syscall" +) + +// MachineMemory is how much memory this machine gives this program - see +// machinememory.go for what the number is used for. +// +// hw.memsize is the memory fitted, as eight bytes in the machine's own order. +// macOS has no control groups, and a process limit set with ulimit is not +// read: it is rare, and a Go program under a small one fails long before it +// gets here. +// +// syscall.Sysctl hands the answer back as a string and drops its last byte when +// that byte is nought (syscall_bsd.go in Go 1.27.3, read 2026-10-27), which is +// right for the text answers it was written for and wrong for this one: the +// highest byte of the size of any real machine is nought, so what arrives is +// seven bytes. Put back before it is read, or the read is of a short buffer. +func MachineMemory() (int64, error) { + answer, err := syscall.Sysctl("hw.memsize") + if err != nil { + return 0, err + } + raw := []byte(answer) + // hw.memsize is a uint64_t (sys/sysctl.h of the macOS 26 SDK, read + // 2026-10-28), so a longer answer can only come from a change in + // syscall.Sysctl or in the kernel's type - a fault in what this program + // assumes rather than something a person did. A defect + // rather than a sentence: a sentence in this file alone would be in the + // window's catalogue on macOS and missing from it everywhere else. + if len(raw) > 8 { + return 0, Defect(fmt.Errorf("hw.memsize came back as %d bytes rather than eight", len(raw))) + } + for len(raw) < 8 { + raw = append(raw, 0) + } + return smallest(binary.NativeEndian.Uint64(raw)), nil +} diff --git a/internal/core/machinememory_linux.go b/internal/core/machinememory_linux.go new file mode 100644 index 00000000..5dda22ff --- /dev/null +++ b/internal/core/machinememory_linux.go @@ -0,0 +1,37 @@ +package core + +import ( + "os" + "syscall" +) + +// MachineMemory is how much memory this machine gives this program - see +// machinememory.go for what the number is used for. +// +// The memory fitted comes from sysinfo, the limit of a container from its +// control group (CgroupMemoryLimit), and the answer is the smaller. A limit +// that cannot be read is an error rather than the memory fitted: in a +// container that is the number the process would be ended for passing, and a +// run told the size of the host is the run R02 was about. +func MachineMemory() (int64, error) { + var info syscall.Sysinfo_t + if err := syscall.Sysinfo(&info); err != nil { + return 0, err + } + // The unit is the size of one of the numbers sysinfo counts in. Kernels + // before 2.3.23 did not set it and counted in bytes. + unit := uint64(info.Unit) + if unit == 0 { + unit = 1 + } + fitted := uint64(info.Totalram) * unit + + limit, limited, err := CgroupMemoryLimit(os.ReadFile) + if err != nil { + return 0, err + } + if limited { + return smallest(fitted, uint64(limit)), nil + } + return smallest(fitted), nil +} diff --git a/internal/core/machinememory_other.go b/internal/core/machinememory_other.go new file mode 100644 index 00000000..97a046ef --- /dev/null +++ b/internal/core/machinememory_other.go @@ -0,0 +1,12 @@ +//go:build !linux && !darwin && !windows + +package core + +import "errors" + +// MachineMemory has no way to ask a system this build does not know, and says +// so rather than guessing. Auto then works to MaxPlanBytes and the run says it +// did - see Memory.On. +func MachineMemory() (int64, error) { + return 0, errors.ErrUnsupported +} diff --git a/internal/core/machinememory_windows.go b/internal/core/machinememory_windows.go new file mode 100644 index 00000000..11269ba3 --- /dev/null +++ b/internal/core/machinememory_windows.go @@ -0,0 +1,45 @@ +//go:build windows + +package core + +import "unsafe" + +// MachineMemory is how much memory this machine gives this program - see +// machinememory.go for what the number is used for. +// +// GlobalMemoryStatusEx answers two numbers that matter. ullTotalPhys is the +// memory in the machine. ullTotalPageFile is the commit limit "for the system +// or the current process, whichever is smaller" (Microsoft Learn, +// MEMORYSTATUSEX, read 2026-10-27), which is where a limit put on a job this +// process runs in shows up. The address space is the third, and on a 64 bit +// build it is never the smallest - it is taken anyway, because it costs one +// comparison and a 32 bit build would otherwise be told about memory it cannot +// reach. +func MachineMemory() (int64, error) { + status := memoryStatusEx{} + // The call refuses a structure whose length it was not told, so this is + // set before it rather than being a field the system fills in. + status.length = uint32(unsafe.Sizeof(status)) + if r, _, e := globalMemoryStatus.Call(uintptr(unsafe.Pointer(&status))); r == 0 { + return 0, e + } + return smallest(status.totalPhys, status.totalPageFile, status.totalVirtual), nil +} + +// memoryStatusEx is MEMORYSTATUSEX, field for field. +type memoryStatusEx struct { + length uint32 + memoryLoad uint32 + totalPhys uint64 + availPhys uint64 + totalPageFile uint64 + availPageFile uint64 + totalVirtual uint64 + availVirtual uint64 + availExtendedVirtual uint64 +} + +// In kernel32 beside GetDiskFreeSpaceExW, and looked up the same way - see the +// note on kernel32 in diskspace_windows.go for why a lazy lookup of this +// library is safe and of an ordinary one would not be. +var globalMemoryStatus = kernel32.NewProc("GlobalMemoryStatusEx") diff --git a/internal/core/memory.go b/internal/core/memory.go new file mode 100644 index 00000000..12399677 --- /dev/null +++ b/internal/core/memory.go @@ -0,0 +1,201 @@ +package core + +import ( + "errors" + "fmt" + "strings" +) + +// The one number that says how much memory a run may take. +// +// docs/STABILITY-REVIEW-2026-10-26.md section 10.8, the owner's decisions of +// 2026-10-27 in 10.10. Before it there were two ceilings and neither could be +// moved: MaxPlanBytes refused a plan past two gigabytes on a machine with +// sixty four, and nothing at all held the writers back, so sixteen pictures of +// forty megapixels written at once took 2.6 GB on a machine where one took 358 +// MB (section 3.2). The owner's principle is that somebody may want files of +// two hundred gigabytes or a great many of them, from either surface, and this +// tool must not be what stops them - so the number is theirs to set, and what +// it does when nobody sets it is worked out from the machine rather than +// written into the build. +// +// Here rather than in the engine because both surfaces read it: the command +// line from a flag and the window from what it remembers, and a value checked +// on one side only is one somebody reaches through the other - the argument +// that put ParseSize in this package. + +// SettingMemory is the name a refusal about the memory setting is about. It is +// the flag's name, because the command line is where the setting is typed as +// text, and a window puts the label of its own box in the slot instead. +const SettingMemory = "memory" + +// Memory is how much memory one run may take, the plan and the writing +// together. +// +// The zero value is auto: half of what this machine gives the program +// (MachineMemory), so a caller that never heard of the setting gets the safe +// answer rather than no limit at all. A size is what somebody asked for, and +// off is no limit - the writers are not held back and the plan has no ceiling, +// at the risk of the system ending the process, which is the person's choice. +type Memory struct { + bytes int64 + off bool +} + +// MemoryOff is no limit at all. +func MemoryOff() Memory { return Memory{off: true} } + +// MemoryOf is a size somebody chose. Nought or less is not a size and is +// read as auto, which is what ParseMemory refuses it for being. +func MemoryOf(bytes int64) Memory { + if bytes <= 0 { + return Memory{} + } + return Memory{bytes: bytes} +} + +// IsAuto, IsOff and Bytes say which of the three a setting is, and the size +// when it is one. +func (m Memory) IsAuto() bool { return !m.off && m.bytes == 0 } +func (m Memory) IsOff() bool { return m.off } +func (m Memory) Bytes() int64 { return m.bytes } + +// The two words, spelled once for the parser, String and the help. +const ( + memoryAuto = "auto" + memoryOff = "off" +) + +// String is the setting the way ParseMemory reads it back: auto, off, or the +// size in the largest unit that holds it whole. A window keeps this text and +// a later start reads it through ParseMemory, so the two must agree to the +// byte - 1.5 GiB comes back as 1536mb rather than as a rounded 1.5gb. +func (m Memory) String() string { + switch { + case m.off: + return memoryOff + case m.bytes == 0: + return memoryAuto + } + for _, u := range []struct { + name string + size int64 + }{{"tb", 1 << 40}, {"gb", 1 << 30}, {"mb", 1 << 20}, {"kb", 1 << 10}} { + if m.bytes%u.size == 0 { + return fmt.Sprintf("%d%s", m.bytes/u.size, u.name) + } + } + return fmt.Sprintf("%d", m.bytes) +} + +// ParseMemory reads the setting as somebody wrote it: auto, off, or a size in +// the units every size in this tool takes. +// +// Nought is refused rather than read as no limit. Off is the word for that, the +// same as --far-over off, and a nought typed by mistake would otherwise take +// every limit away without a word. A size of one byte is accepted: it is a +// strange thing to ask for and it does what it says, a refusal for every plan +// and every picture written alone. +func ParseMemory(s string) (Memory, error) { + raw := strings.TrimSpace(s) + switch strings.ToLower(raw) { + case memoryAuto: + return Memory{}, nil + case memoryOff: + return MemoryOff(), nil + case "": + return Memory{}, RefuseAbout(SettingMemory, Says("core.MemoryEmpty", "empty {setting}: write auto, off or a size such as 8gb")) + } + if !strings.ContainsAny(raw, "0123456789") { + return Memory{}, RefuseAbout(SettingMemory, Says("core.MemoryUnknown", + "{setting} %q is not a setting this tool knows: write auto for half of this machine's memory, off for no limit, or a size such as 8gb", + A("Value", s))) + } + if strings.HasPrefix(raw, "-") { + return Memory{}, RefuseAbout(SettingMemory, Says("core.MemoryNegative", + "{setting} %q is negative: write auto, off or a size such as 8gb", A("Value", s))) + } + n, err := ParseSize(raw) + if err != nil { + return Memory{}, aboutMemory(err) + } + if n == 0 { + return Memory{}, RefuseAbout(SettingMemory, Says("core.MemoryZero", + "{setting} %q leaves no room at all: write off for no limit, or a size such as 8gb", A("Value", s))) + } + return Memory{bytes: n}, nil +} + +// ParseMemorySize is ParseMemory for a box that holds a size and nothing else +// - the window's Custom, where auto and off are chosen on the switch above it +// and a word typed into the box would be a second way to the same choice. +// +// Anything with no digit in it is refused in those words, the two words of the +// switch among them - ParseMemory would answer "lots" by offering auto and off, +// which is advice for a different box. +func ParseMemorySize(s string) (Memory, error) { + if !strings.ContainsAny(s, "0123456789") { + return Memory{}, RefuseAbout(SettingMemory, Says("core.MemoryNotASize", + "{setting} %q is not a size: write one such as 8gb", A("Value", s))) + } + return ParseMemory(s) +} + +// aboutMemory is a refusal of ParseSize, put in the memory slot. The size +// parser says what is wrong with the number better than a second sentence +// could, and it names its own setting, which here is the wrong one: the +// person typed --memory, and "size" would send them to a flag they did not +// write. +func aboutMemory(err error) error { + var r *Refusal + if errors.As(err, &r) { + return &Refusal{said: r.said, setting: SettingMemory} + } + return err +} + +// MemoryLimits is a setting worked out on one machine. +type MemoryLimits struct { + // Run is what the run may hold while it writes, the plan among it. Zero + // is no limit. + Run int64 + // Plan is the ceiling on the plan. Zero is no ceiling. + Plan int64 + // Unknown is why the machine could not be asked, when it could not. Auto + // then works to MaxPlanBytes, the ceiling every run had before the + // setting existed, and a run says so once rather than pretending the + // number came from the machine. + Unknown error +} + +// On works the setting out against the memory of one machine, asked through +// machine - MachineMemory for a real run, a number for a guard. +// +// Auto is half, because a run is not alone on the machine and the plan and +// the writers share the one number. The plan's ceiling under auto never drops +// below MaxPlanBytes, so no run that was planned before this setting existed +// is refused now - on a machine of four gigabytes or less, auto gives the +// writers less than that and leaves the plan where it always was. A size is +// the person's own choice and is used as given, downwards too. +// +// machine is not called for a setting that does not need it, so a size and +// off never touch the system. +func (m Memory) On(machine func() (int64, error)) MemoryLimits { + switch { + case m.off: + return MemoryLimits{} + case m.bytes > 0: + return MemoryLimits{Run: m.bytes, Plan: m.bytes} + } + total, err := machine() + if err == nil && total <= 1 { + // An answer, but not one about a machine that runs anything, so it is + // treated as no answer rather than halved into a limit of nothing. + err = Refuse(Says("core.MachineMemoryNone", "the system said this machine has %d B of memory", A("Bytes", total))) + } + if err != nil { + return MemoryLimits{Run: MaxPlanBytes, Plan: MaxPlanBytes, Unknown: err} + } + half := total / 2 + return MemoryLimits{Run: half, Plan: max(half, MaxPlanBytes)} +} diff --git a/internal/engine/engine.go b/internal/engine/engine.go index 67769d30..f4267528 100644 --- a/internal/engine/engine.go +++ b/internal/engine/engine.go @@ -135,15 +135,26 @@ type Options struct { // the guard is broken. Nil means ask the operating system. AvailableBytes func(path string) (int64, error) - // MaxPlanBytes is the ceiling on what the plan may cost in memory. Zero - // means core.MaxPlanBytes, which is what every real caller passes. + // Memory is how much memory this run may take, the plan and the writing + // together - the --memory flag, or Memory on the window's Preferences + // screen. The zero value is auto, so a caller that never heard of it gets + // half of what the machine gives the program rather than no limit. // - // Injected for the same reason AvailableBytes above it is: a test of this - // guard would otherwise have to allocate two gigabytes to reach it, which - // is slower than the whole suite and would sit inside the mutation - // runner's own memory cap. With a small ceiling the mechanism is exercised - // on a handful of files, and the real number is asked about separately. - MaxPlanBytes int64 + // It sets two things (core.Memory.On): the ceiling the plan is refused + // at, and how much the files being written at once may hold between them + // before the next one waits (writeAll). The second never refuses anything. + // + // A small size is also how a test reaches the plan's ceiling. The ceiling + // used to be a field of its own, MaxPlanBytes, injected so a guard need + // not allocate two gigabytes to get there - and a second way in to one + // number is a way for the two to come apart, so it went on 2026-10-27. + Memory core.Memory + + // MachineMemory reports how much memory this machine gives the program, + // which auto halves. Nil means ask the system (core.MachineMemory). + // Injected for the reason AvailableBytes is: a test describes a machine of + // two gigabytes without owning one. + MachineMemory func() (int64, error) // Record is the forecast of this run's record, when the caller has already // worked it out for these same files with PredictRecord - the command line @@ -185,6 +196,17 @@ func (o Options) availableBytes(path string) (int64, error) { return core.AvailableBytes(path) } +// memoryLimits is the memory setting worked out on this machine. Asked once +// by planning and once by the run, rather than carried between them, because +// the two are separate calls and a caller may plan without running. +func (o Options) memoryLimits() core.MemoryLimits { + machine := o.MachineMemory + if machine == nil { + machine = core.MachineMemory + } + return o.Memory.On(machine) +} + // Result is what a run produced. type Result struct { Manifest *manifest.Manifest @@ -207,6 +229,13 @@ type Result struct { // the notes in its own language from here, and the manifest stays English. NotesSaid map[string]core.Said + // RunNotes is what the run says about itself rather than about a file: + // the memory of the machine could not be read, a file was written past + // the memory setting. Not in the manifest, which describes files - these + // describe the machine the run happened on, and a manifest that changed + // with the machine would record something other than the files beside it. + RunNotes []core.Said + // Record is how large the record of this run was worked out to be before // its first byte, the manifest and the instructions (PredictRecord). For a // dry run that is the answer about the run it previews. A run that is @@ -372,7 +401,7 @@ func PlanContext(ctx context.Context, targets []Target, opt Options) ([]PlannedF // Watches what the plan costs while it is being built. Constructed here // rather than at the first file because constructing it is what takes the // reference point, and that has to happen before any of the plan exists. - pl.budget = newPlanMemory(opt.MaxPlanBytes) + pl.budget = newPlanMemory(opt.Memory, opt.memoryLimits()) // The manifest lands beside the files, so its name is a name too. A path // here would leave a manifest outside the directory the run was pointed @@ -495,6 +524,8 @@ func Run(ctx context.Context, files []PlannedFile, opt Options) (*Result, error) if err := preflight(ctx, files, opt, record); err != nil { return res, err } + limits := opt.memoryLimits() + res.noteMemory(limits) if opt.DryRun { // Nothing is written. Every entry is what would have been produced, @@ -570,7 +601,9 @@ func Run(ctx context.Context, files []PlannedFile, opt Options) (*Result, error) // The files are written over several goroutines. Everything that runs // beside anything else lives in parallel.go, including the measurements // that put it there. - written := writeAll(ctx, files, opt.OutDir, newProgressGate(files, opt.OnProgress)) + queue := queueFor(files, limits.Run, widthFor(len(files))) + written := writeAll(ctx, files, opt.OutDir, newProgressGate(files, opt.OnProgress), queue) + res.noteOverrun(queue.Overrun()) // Read back in the order the plan lists, on this goroutine alone. Two // things rest on that and neither is tidiness: diff --git a/internal/engine/parallel.go b/internal/engine/parallel.go index e1dea444..ed6af43b 100644 --- a/internal/engine/parallel.go +++ b/internal/engine/parallel.go @@ -13,6 +13,7 @@ import ( "os" "path/filepath" "runtime" + "slices" "sync" "sync/atomic" @@ -50,10 +51,15 @@ import ( // are live at once, so the memory a run holds while writing is N times one // generator's working set. Every generator streams - there is a guard for that // - so that set is bounded by the format rather than by the file, and the -// largest of them hold one picture. It is still N times what it was. And the -// answers are collected before any of them reaches the manifest, which is -// forty bytes a file that the sequential loop did not need: forty megabytes at -// the millionth file, beside a plan that is already far larger. +// largest of them hold one picture. A picture is enough: sixteen of forty +// megapixels held 2.6 GB where one held 358 MB, measured on 2026-10-26 +// (docs/STABILITY-REVIEW-2026-10-26.md section 3.2). So since 2026-10-27 a +// file that holds memory says how much (format.Plan.Holds), and memoryQueue +// below keeps the files written at once inside what the memory setting leaves +// after the plan - by waiting, never by refusing. And the answers are +// collected before any of them reaches the manifest, which is forty bytes a +// file that the sequential loop did not need: forty megabytes at the +// millionth file, beside a plan that is already far larger. // widthFor is how many goroutines a run of n files gets. // @@ -106,18 +112,14 @@ type fileResult struct { // wants the opposite - a contiguous prefix - because a sentence about an // interrupted verify is a sentence about a prefix, and that difference is why // there are two pools rather than one shared one. -func writeAll(ctx context.Context, files []PlannedFile, outDir string, gate *progressGate) []fileResult { - out := make([]fileResult, len(files)) - - // next is the only thing every goroutine touches, and it is an atomic - // counter. out is written at indices no other goroutine is given, because - // the counter hands each index out exactly once. - var next atomic.Int64 +func writeAll(ctx context.Context, files []PlannedFile, outDir string, gate *progressGate, queue *memoryQueue) []fileResult { + w := &writers{files: files, outDir: outDir, gate: gate, queue: queue, out: make([]fileResult, len(files))} var wg sync.WaitGroup + defer queue.wakeOnStop(ctx)() work := func() { defer wg.Done() - drain(ctx, &next, files, outDir, gate, out) + w.drain(ctx) } for w := widthFor(len(files)); w > 0; w-- { wg.Add(1) @@ -125,7 +127,22 @@ func writeAll(ctx context.Context, files []PlannedFile, outDir string, gate *pro } wg.Wait() - return out + return w.out +} + +// writers is what every writer of one run shares: the files and where they go, +// the bar, the memory queue, and the answers. +// +// next is the only thing every goroutine changes, and it is an atomic counter. +// out is written at indices no other goroutine is given, because the counter +// hands each index out exactly once. +type writers struct { + files []PlannedFile + outDir string + gate *progressGate + queue *memoryQueue + out []fileResult + next atomic.Int64 } // drain takes files off the counter until there are none left. @@ -134,9 +151,9 @@ func writeAll(ctx context.Context, files []PlannedFile, outDir string, gate *pro // reason written beside the same split in internal/audit: a literal inside a // loop counts one level deeper than it reads, and the shape guard asks for the // split rather than for a larger ceiling. -func drain(ctx context.Context, next *atomic.Int64, files []PlannedFile, outDir string, gate *progressGate, out []fileResult) { +func (w *writers) drain(ctx context.Context) { for { - i := int(next.Add(1)) - 1 + i := int(w.next.Add(1)) - 1 // Cancellation is asked before taking a file rather than only at the // top of a pass, so a Ctrl+C is noticed at the next file rather than // after every remaining one has been begun. @@ -148,18 +165,188 @@ func drain(ctx context.Context, next *atomic.Int64, files []PlannedFile, outDir // had left, which on a million file run is a million of them. What it // buys is work not done, and the guard that would catch its absence // would be a guard on a clock. - if i >= len(out) || ctx.Err() != nil { + if i >= len(w.out) || ctx.Err() != nil { return } // One of these per file, on this goroutine's own stack. How far this // writer has already reported is the one number that cannot live in // the gate: with several files in flight there is no such thing as // "the file being written". - p := fileProgress{gate: gate} - sum, err := writeOne(ctx, files[i], outDir, &p) - out[i] = fileResult{sha: sum, ok: err == nil, err: err} - p.finished(files[i].Plan, err == nil) + p := fileProgress{gate: w.gate} + // A file that holds memory waits here for its turn and its room. A + // stop while it waits leaves it never started - the zero answer - + // exactly like the files this writer never took. + if !w.queue.take(ctx, i) { + return + } + sum, err := writeOne(ctx, w.files[i], w.outDir, &p) + w.queue.give(i) + w.out[i] = fileResult{sha: sum, ok: err == nil, err: err} + p.finished(w.files[i].Plan, err == nil) + } +} + +// memoryQueue holds the files written at once to the memory the setting leaves +// them - docs/STABILITY-REVIEW-2026-10-26.md sections 10.1 and 10.8. +// +// Three rules, and each is there because the obvious alternative breaks. +// +// - A file that holds nothing never waits. Most formats stream, and a txt +// queued behind a picture would be slower for no memory at all. +// - Files that hold something take their turn in the order of the plan, and +// a later one never goes first. Letting a small picture past a large one +// that does not fit yet is how the large one waits for ever. +// - A file that holds more than all the room there is goes when nothing else +// holds anything, on its own. Waiting for room that can never exist would +// be a run that never ends, and refusing it would be the setting refusing +// files, which the owner ruled out on 2026-10-27. The run then goes past the +// setting while that file is written, and says so once (Overrun). +// +// Nil is no queue: --memory off, or room enough that every writer could hold +// its largest file at once. Both are a run written exactly as it was before +// this existed, and every method takes a nil receiver. +type memoryQueue struct { + mu sync.Mutex + turned *sync.Cond + files []PlannedFile + rank []int // place in the queue by index, -1 for a file that holds nothing + turn int // the place whose turn it is + room int64 // what is left, below nought only while a file goes on its own + holding int // files holding something now + over *Overrun +} + +// Overrun is a file that held more than the memory setting left the writers, +// and was written on its own. +type Overrun struct { + Name string + Holds, Room int64 +} + +// queueFor is the queue for one run, or nil when the run needs none. limit is +// what the memory setting lets the run hold (core.MemoryLimits.Run), nought for +// off, and width how many writers there are. +func queueFor(files []PlannedFile, limit int64, width int) *memoryQueue { + need := widest(files, width) + if limit <= 0 || need == 0 { + return nil + } + room := roomLeft(limit, need) + if room >= need { + return nil + } + q := &memoryQueue{files: files, room: room, rank: make([]int, len(files))} + q.turned = sync.NewCond(&q.mu) + places := 0 + for i, f := range files { + q.rank[i] = -1 + if f.Plan.Holds > 0 { + q.rank[i] = places + places++ + } + } + return q +} + +// widest is the most the writers could hold at once: the width largest files. +// When the room is at least that, nothing would ever wait, so there is no +// queue - and a run of text files costs the arithmetic and nothing else. +// +// One pass, keeping the width largest seen so far and replacing the smallest of +// them - width is the number of writers, sixteen on this machine, so a million +// files is sixteen million comparisons rather than a sort of a million. +func widest(files []PlannedFile, width int) int64 { + largest := make([]int64, max(width, 1)) + for _, f := range files { + if f.Plan.Holds > 0 { + least := slices.Index(largest, slices.Min(largest)) + largest[least] = max(largest[least], f.Plan.Holds) + } + } + var sum int64 + for _, n := range largest { + sum += n + } + return sum +} + +// take waits for file i's turn and room, and takes the room. False is a stop +// that came while it waited, and then nothing is taken. +// +// The stop is asked of the context itself, under the lock, rather than of a +// flag the stop sets. A flag would be set by the callback wakeOnStop hands the +// context, on a goroutine of its own and a moment later - so a writer woken by +// give in that moment would see no stop and start a file of a run that was +// already stopped. +func (q *memoryQueue) take(ctx context.Context, i int) bool { + if q == nil || q.rank[i] < 0 { + return true + } + holds := q.files[i].Plan.Holds + q.mu.Lock() + defer q.mu.Unlock() + for ctx.Err() == nil && (q.turn != q.rank[i] || (holds > q.room && q.holding > 0)) { + q.turned.Wait() + } + if ctx.Err() != nil { + return false + } + if holds > q.room && q.over == nil { + q.over = &Overrun{Name: q.files[i].Name, Holds: holds, Room: max(q.room, 0)} + } + q.room -= holds + q.holding++ + q.turn++ + q.turned.Broadcast() + return true +} + +// give hands file i's room back, whether it was written or failed. +func (q *memoryQueue) give(i int) { + if q == nil || q.rank[i] < 0 { + return + } + q.mu.Lock() + defer q.mu.Unlock() + q.room += q.files[i].Plan.Holds + q.holding-- + q.turned.Broadcast() +} + +// wakeOnStop wakes every writer waiting in the queue when the run is stopped. +// The function it returns undoes that, once the writers are done. +// +// give wakes them too, so most stops would be answered without this - a file +// holding the room finishes, gives it back and the writers waiting see the +// stop. What give cannot reach is a writer waiting for the turn of a file that +// will never be taken: drain hands out an index and then, seeing the stop, +// returns without writing it, so the turn that file held never moves and +// nothing holds anything that will be given back. Without this the writers +// behind it sleep for ever and the run never ends. +// +// NO GUARD PROVES THIS, and it is not for want of trying. The stop has to land +// between one writer taking an index and asking about the stop, while a writer +// behind it is already waiting - an order of goroutines a test cannot arrange +// from outside the package. What stays is the reasoning above, and the cost of +// being wrong about it is a run that hangs on Ctrl+C. +func (q *memoryQueue) wakeOnStop(ctx context.Context) func() { + if q == nil { + return func() {} + } + stop := context.AfterFunc(ctx, func() { + q.mu.Lock() + defer q.mu.Unlock() + q.turned.Broadcast() + }) + return func() { stop() } +} + +// Overrun is the first file that was written past the setting, or nil. +func (q *memoryQueue) Overrun() *Overrun { + if q == nil { + return nil } + return q.over } // progressGate is how progress leaves a run that has several writers in it. diff --git a/internal/engine/planmemory.go b/internal/engine/planmemory.go index 3fc2be08..265e4012 100644 --- a/internal/engine/planmemory.go +++ b/internal/engine/planmemory.go @@ -55,10 +55,12 @@ const ( // // What it can be wrong about is whose heap it is. ReadMemStats answers for the // whole process, so anything else allocating in it while a plan is being built -// is counted against the plan - O162. Two gigabytes is a great deal to borrow -// by accident, and every run this tool was designed around is orders of -// magnitude under the ceiling, so the room for a wrong refusal is small. It is -// not nought, and this is where it lives. +// is counted against the plan - O162. Under auto the ceiling is two gigabytes +// at the least, which is a great deal to borrow by accident, and every run this +// tool was designed around is orders of magnitude under it, so the room for a +// wrong refusal is small. It is not nought, and this is where it lives. A +// person who sets a small size has chosen a ceiling the window's own heap is +// counted against too. type planMemory struct { baseline uint64 // allocsBaseline is the running total of bytes this process has ever @@ -74,6 +76,10 @@ type planMemory struct { seen int nextAt int ceiling uint64 + // setting and limits are where the ceiling came from, kept for the one + // sentence that has to say so. + setting core.Memory + limits core.MemoryLimits } // newPlanMemory takes the reference point, and it is taken here because here @@ -114,15 +120,20 @@ type planMemory struct { // guard package interleaved on 2026-09-03: 277.9 s and 283.7 s without them // against 292.3 s and 306.1 s with, so about six percent. The ranges do not // overlap, which is the only reason a conclusion is drawn from four runs. -func newPlanMemory(ceiling int64) *planMemory { - if ceiling <= 0 { - ceiling = core.MaxPlanBytes +// +// Nil under --memory off, which asks for no ceiling, and then nothing is +// weighed at all - not even the reference point, which costs a collection. +func newPlanMemory(setting core.Memory, limits core.MemoryLimits) *planMemory { + if limits.Plan <= 0 { + return nil } return &planMemory{ baseline: heapInUse(), allocsBaseline: allocatedSoFar(), nextAt: 1, - ceiling: uint64(ceiling), + ceiling: uint64(limits.Plan), + setting: setting, + limits: limits, } } @@ -139,8 +150,9 @@ func newPlanMemory(ceiling int64) *planMemory { // What the counter cannot do is answer the question this refuses on. It counts // everything ever allocated, including what has already been thrown away, so it // says "no" with certainty and "maybe" otherwise. The certain half is enough, -// because it is the half every real run lands in: MaxPlanBytes is two -// gigabytes and planning ten thousand files allocates tens of megabytes. +// because it is the half every real run lands in: under auto the ceiling is +// two gigabytes at the least, and planning ten thousand files allocates tens of +// megabytes. // // /gc/heap/live:bytes was the obvious other candidate and it is the wrong one. // It reports the live heap as of the LAST COLLECTION, so a burst of allocation @@ -148,6 +160,9 @@ func newPlanMemory(ceiling int64) *planMemory { // silence exactly when the plan is growing fastest. The counter has no such // gap. func (p *planMemory) account(targetIndex, filesSoFar int) error { + if p == nil { + return nil + } p.seen++ if p.seen < p.nextAt { return nil @@ -184,7 +199,7 @@ func (p *planMemory) account(targetIndex, filesSoFar int) error { core.A("Used", core.Bytes(used)), core.A("Files", core.SaysN("engine.FilesSoFar", "%d file", "%d files", core.A("Count", filesSoFar))), core.A("Ceiling", core.Bytes(p.ceiling))), - Because: core.PlanTooLargeWhy, + Because: core.PlanTooLargeWhy(p.setting, p.limits), Remedy: core.PlanTooLargeFix, } } @@ -213,6 +228,36 @@ func stepFor(ceiling, used uint64, seen int) int { return step } +// roomLeft is what the memory setting leaves the writers once the plan and the +// rest of the process are held: limit less the heap. +// +// The heap is read the cheap way first, and that reading is never less than +// what is live - it counts what the collector has not swept yet too - so room +// it says is there is there. Only when it says there may not be room for need, +// the most the writers could hold at once, is the heap read after a collection, +// which costs what account below says it does. A run of text files never gets +// here at all (queueFor), and a run of pictures pays one collection at most, +// against writing pictures. +// +// The whole heap rather than the plan alone, and the difference is the window: +// what it holds is memory the process holds, and the setting is about what the +// process may take. On the command line the two differ by a few megabytes. +func roomLeft(limit, need int64) int64 { + if room := limit - int64(heapHeldAtMost()); room >= need { + return room + } + return limit - int64(heapInUse()) +} + +// heapHeldAtMost is the heap as it stands, the live objects and the ones not +// swept yet, without a collection - an upper bound on what is live, at the +// cost of one reading. +func heapHeldAtMost() uint64 { + sample := [1]metrics.Sample{{Name: "/memory/classes/heap/objects:bytes"}} + metrics.Read(sample[:]) + return sample[0].Value.Uint64() +} + // allocatedSoFar is every byte this process has ever allocated, thrown away or // not. // diff --git a/internal/engine/runnotes.go b/internal/engine/runnotes.go new file mode 100644 index 00000000..d2acd1a2 --- /dev/null +++ b/internal/engine/runnotes.go @@ -0,0 +1,37 @@ +// Part of package engine. See engine.go. +package engine + +import "github.com/donislawdev/TestingFilesGenerator/internal/core" + +// What a run says about the memory it ran in - Result.RunNotes. +// +// Both are said once per run rather than once per file, for the reason +// manifest.Notes gives: the same sentence twenty five thousand times buries the +// line that matters. And neither reaches the manifest, because both are about +// the machine rather than about any file on it. + +// noteMemory says the machine could not be asked, when it could not. Auto then +// worked to the old ceiling, and a run that does not say so has a limit nobody +// can see the source of (untouchable rule 6). +func (res *Result) noteMemory(limits core.MemoryLimits) { + if limits.Unknown == nil { + return + } + res.RunNotes = append(res.RunNotes, core.Says("engine.MemoryUnknown", + "the memory of this machine could not be read (%v), so this run worked to %s, the most any run took before the memory setting existed. "+ + "Set the memory setting yourself to choose the limit", + core.A("Cause", limits.Unknown), core.A("Limit", core.Bytes(core.MaxPlanBytes)))) +} + +// noteOverrun says a file was written past the memory setting, when one was. +// Only the first is named. The rest of the run's shape is the same question +// asked again, and the answer to all of them is the one setting. +func (res *Result) noteOverrun(o *Overrun) { + if o == nil { + return + } + res.RunNotes = append(res.RunNotes, core.Says("engine.MemoryOverrun", + "%s holds about %s while it is written, more than the %s the memory setting left the files being written, "+ + "so it was written on its own and the run went past the setting until it finished", + core.A("Name", core.Shown(o.Name)), core.A("Holds", core.Bytes(o.Holds)), core.A("Room", core.Bytes(o.Room)))) +} diff --git a/internal/format/avif/avif.go b/internal/format/avif/avif.go index cb71e5c3..a2973bc1 100644 --- a/internal/format/avif/avif.go +++ b/internal/format/avif/avif.go @@ -208,9 +208,27 @@ func (generator) Plan(r format.Request) (format.Plan, error) { carries, p.Notes = imagelabel.Claim(carries, m.width, m.height, len(label), p.Notes) p.Properties[format.PropertyLabelEmbedded] = carries p.Memo = m + if m.coded == nil { + // Coded while writing rather than while planning, so the writer holds + // the picture and the encoder's work - see holdsPerPixel. + p.Holds = holdsPerPixel * int64(m.width) * int64(m.height) + } return p, nil } +// holdsPerPixel is what coding one of these holds at once for each pixel, for +// the memory setting (format.Plan.Holds). Only a picture chosen from the +// ladder is coded while writing, and the largest rung is 640x480, so this +// declares at most about 2 MB. +// +// From the peak of one picture, measured on 2026-10-26 with +// tools/probes/peakmem (docs/STABILITY-REVIEW-2026-10-26.md section 10.0): +// 347 MiB for one 8000x5000 picture coded while planning, 7.9 B a pixel. That peak holds a whole process's +// collector room around one picture, so it is more than a second writer adds - +// for jpg the second writer added a third of it - which makes it a bound from +// above rather than a measurement of this. +const holdsPerPixel = 8 + // checkFits refuses a request the format cannot answer. // // The gate is the declared floor, not this seed's own encoded size, so the diff --git a/internal/format/container.go b/internal/format/container.go index fd9d209b..a63f137d 100644 --- a/internal/format/container.go +++ b/internal/format/container.go @@ -111,3 +111,19 @@ func Containers() []string { sort.Strings(out) return out } + +// OneAtATime is what a container that writes the files inside it one after +// another holds at once, for the memory setting: the most any one of them holds +// (Plan.Holds). Never their sum - the second picture is written after the +// first has gone - and never nothing, because each of the five containers that +// hold pictures codes them while it writes (zip, targz, htmlfile, rtf and eml, +// docs/STABILITY-REVIEW-2026-10-26.md section 12.0). A container inside a +// container passes its own number up, so the deepest picture is the one that +// counts. +func OneAtATime[T any](inside []T, plan func(T) Plan) int64 { + var most int64 + for _, f := range inside { + most = max(most, plan(f).Holds) + } + return most +} diff --git a/internal/format/eml/eml.go b/internal/format/eml/eml.go index b6ba2c66..cd90b1ac 100644 --- a/internal/format/eml/eml.go +++ b/internal/format/eml/eml.go @@ -271,6 +271,10 @@ func describe(m *message, groups []format.Content, target int64) format.Plan { p.Properties[UnsubscribeURL] = m.id.unsubscribe } describePictures(p.Properties, m) + // The attached files and the pictures the text shows are two lists, and + // the message writes every one of them after the one before. + plan := func(c child) format.Plan { return c.plan } + p.Holds = max(format.OneAtATime(m.children, plan), format.OneAtATime(m.pictures, plan)) return p } diff --git a/internal/format/format.go b/internal/format/format.go index 00fb379f..6daf946d 100644 --- a/internal/format/format.go +++ b/internal/format/format.go @@ -704,6 +704,18 @@ type Plan struct { // as the bytes that would take as long to write - nought for every file // whose cost is its bytes. See work.go. Work int64 + // Holds is how much memory writing the file takes at once beyond the + // stream every file is written through - a whole picture in memory, say. + // Nought for a format that streams, which is most of them. + // + // Counted the way the memory setting is: what the process grew by when one + // file of the shape was written, from the peak a probe measured, so the + // collector's room is inside the number. Read by the writers alone + // (engine/parallel.go), which hold the files written at once to what the + // setting leaves them - docs/STABILITY-REVIEW-2026-10-26.md section 10.8. + // Like Work, it is a cost only the format knows, and like Work it never + // reaches the manifest. + Holds int64 } // PropertyLabelEmbedded is the key a generator sets to say whether the label diff --git a/internal/format/gif/gif.go b/internal/format/gif/gif.go index 5b22f101..7d30b992 100644 --- a/internal/format/gif/gif.go +++ b/internal/format/gif/gif.go @@ -232,9 +232,23 @@ func (generator) Plan(r format.Request) (format.Plan, error) { labelled, p.Notes = imagelabel.Claim(labelled, w, h, len(label), p.Notes) p.Properties[format.PropertyLabelEmbedded] = labelled p.Memo = m + p.Holds = holdsPerPixel * int64(w) * int64(h) / 2 return p, nil } +// holdsPerPixel is twice what writing one of these holds at once for each +// pixel of the picture, for the memory setting (format.Plan.Holds) - one byte +// of palette index a pixel for the first frame, and buffers. Twice, so a +// number with a half in it stays a whole one. +// +// Measured on 2026-10-27 with tools/probes/peakmem: eight files written by +// eight writers against the same eight by one, two rounds, the difference +// shared out over the seven extra writers. 0.4 and 0.75 B a pixel at +// 2000x1250, 0.75 and 0.85 at 4000x2500 - the frames after the first redraw +// only the square that moves, so they add little. One and a half covers both +// with room. +const holdsPerPixel = 3 + // settlePadding decides what the comment has to carry, or refuses a size no // arrangement of comments can produce. func settlePadding(m *memo, want, bare int64) error { diff --git a/internal/format/htmlfile/html.go b/internal/format/htmlfile/html.go index b5b594e1..5b95ae1f 100644 --- a/internal/format/htmlfile/html.go +++ b/internal/format/htmlfile/html.go @@ -143,6 +143,7 @@ func (generator) PlanContext(ctx context.Context, r format.Request) (format.Plan p.Properties[format.PropertyLabelEmbedded] = m.labelLine != "" p.Memo = m + p.Holds = format.OneAtATime(m.pictures, func(l pictureLine) format.Plan { return l.plan }) return p, nil } diff --git a/internal/format/jpg/jpg.go b/internal/format/jpg/jpg.go index edadddff..b103c7e5 100644 --- a/internal/format/jpg/jpg.go +++ b/internal/format/jpg/jpg.go @@ -256,9 +256,23 @@ func (generator) Plan(r format.Request) (format.Plan, error) { labelled, p.Notes = imagelabel.Claim(labelled, w, h, len(label), p.Notes) p.Properties[format.PropertyLabelEmbedded] = labelled p.Memo = m + p.Holds = holdsPerPixel * int64(w) * int64(h) return p, nil } +// holdsPerPixel is what writing one of these holds at once for each pixel of +// the picture, for the memory setting (format.Plan.Holds) - the picture the +// encoder reads, and its buffers. +// +// Measured on 2026-10-27 with tools/probes/peakmem: eight files written by +// eight writers against the same eight by one, two rounds, the difference +// shared out over the seven extra writers. 3.65 B a pixel at 2000x1250, 2.9 at +// 4000x2500, and 4.0 at 8000x5000 from the sixteen writers of 2026-10-26 +// (docs/STABILITY-REVIEW-2026-10-26.md section 3.2). Five covers all three with +// room, because a number under the truth lets the writers past the setting and +// one over it only makes them wait. +const holdsPerPixel = 5 + func (generator) Write(ctx context.Context, w io.Writer, p format.Plan) error { m, ok := p.Memo.(memo) if !ok { diff --git a/internal/format/jxl/jxl.go b/internal/format/jxl/jxl.go index e2e963b1..0bd09a8f 100644 --- a/internal/format/jxl/jxl.go +++ b/internal/format/jxl/jxl.go @@ -254,9 +254,27 @@ func (generator) Plan(r format.Request) (format.Plan, error) { carries, p.Notes = imagelabel.Claim(carries, m.width, m.height, len(label), p.Notes) p.Properties[format.PropertyLabelEmbedded] = carries p.Memo = m + if m.coded == nil { + // Coded while writing rather than while planning, so the writer holds + // the picture and the encoder's work - see holdsPerPixel. + p.Holds = holdsPerPixel * int64(m.width) * int64(m.height) + } return p, nil } +// holdsPerPixel is what coding one of these holds at once for each pixel, for +// the memory setting (format.Plan.Holds). Only a picture chosen from the +// ladder is coded while writing, and the largest rung is 640x480, so this +// declares at most about 10 MB. +// +// From the peak of one picture, measured on 2026-10-26 with +// tools/probes/peakmem (docs/STABILITY-REVIEW-2026-10-26.md section 10.0): +// 1306 MiB for one 8000x5000 picture coded while planning, 33 B a pixel, and 28 to 29 at 2.5 and 10 megapixels. That peak holds a whole process's +// collector room around one picture, so it is more than a second writer adds - +// for jpg the second writer added a third of it - which makes it a bound from +// above rather than a measurement of this. +const holdsPerPixel = 34 + // checkFits refuses a request the format cannot answer. // // The gate is the declared floor, not this seed's own coded size, so the diff --git a/internal/format/png/png.go b/internal/format/png/png.go index 68d439a4..01daba80 100644 --- a/internal/format/png/png.go +++ b/internal/format/png/png.go @@ -248,9 +248,22 @@ func (generator) Plan(r format.Request) (format.Plan, error) { labelled, p.Notes = imagelabel.Claim(labelled, w, h, len(label), p.Notes) p.Properties[format.PropertyLabelEmbedded] = labelled p.Memo = m + p.Holds = holdsPerPixel * int64(w) * int64(h) return p, nil } +// holdsPerPixel is what writing one of these holds at once for each pixel of +// the picture, for the memory setting (format.Plan.Holds) - the picture the +// encoder reads, and its buffers. +// +// Measured on 2026-10-27 with tools/probes/peakmem: eight files written by +// eight writers against the same eight by one, two rounds, the difference +// shared out over the seven extra writers. 2.9 and 3.0 B a pixel at 2000x1250, +// 2.5 and 3.5 at 4000x2500. Five covers both with room, because a number under +// the truth lets the writers past the setting and one over it only makes them +// wait. +const holdsPerPixel = 5 + func (generator) Write(ctx context.Context, w io.Writer, p format.Plan) error { m, ok := p.Memo.(memo) if !ok { diff --git a/internal/format/rtf/rtf.go b/internal/format/rtf/rtf.go index b43d42c0..3f2749ec 100644 --- a/internal/format/rtf/rtf.go +++ b/internal/format/rtf/rtf.go @@ -105,6 +105,7 @@ func (generator) PlanContext(ctx context.Context, r format.Request) (format.Plan } p.Properties[format.PropertyLabelEmbedded] = m.labelLine != "" p.Memo = m + p.Holds = format.OneAtATime(m.pictures, func(l pictureLine) format.Plan { return l.plan }) return p, nil } diff --git a/internal/format/targz/targz.go b/internal/format/targz/targz.go index 108e94a4..fd4715dd 100644 --- a/internal/format/targz/targz.go +++ b/internal/format/targz/targz.go @@ -250,6 +250,7 @@ func (generator) PlanContext(ctx context.Context, r format.Request) (format.Plan } p.Memo = m + p.Holds = format.OneAtATime(m.children, func(c child) format.Plan { return c.plan }) return p, nil } diff --git a/internal/format/video/plan.go b/internal/format/video/plan.go index ed6b44b6..29c1afc6 100644 --- a/internal/format/video/plan.go +++ b/internal/format/video/plan.go @@ -52,6 +52,7 @@ func Plan(formatID string, r format.Request, need func(Stream) int64, refuse fun Properties: Facts(c, s), Memo: Film{Choice: c, Settings: s}, Work: st.Work(), + Holds: Holds(c.Width, c.Height), } if r.Label && !Labelled(c.Width, c.Label) { p.Notes = append(p.Notes, format.Note{ @@ -72,6 +73,22 @@ func Plan(formatID string, r format.Request, need func(Stream) int64, refuse fun return p, nil } +// Holds is what writing a film of a picture this size holds at once, for the +// memory setting (format.Plan.Holds). +// +// Measured on 2026-10-27 with tools/probes/peakmem: eight films of one second +// written by eight writers against the same eight by one, two rounds, the +// difference shared out over the seven extra writers. 5.0 and 8.0 MiB a writer +// at 1920x1080, 14.1 and 14.9 at 3840x2160 - so a part that does not grow with +// the picture and a part that does, and the pool of helpers in ahead.go is the +// process's rather than any writer's, so it is in neither. Four mebibytes and +// two bytes a pixel comes to 8.1 and 20.6 MiB at those two sizes, over every +// reading. Measured at one second: a longer film is written a picture at a +// time, and NOT measured to hold no more. +func Holds(width, height int) int64 { + return 4<<20 + 2*int64(width)*int64(height) +} + // Hint is what a refusal below a film's minimum offers. A smaller picture only // when the request named one: a picture chosen to fit is already the smallest // that would, so offering a smaller one would send somebody to a setting that diff --git a/internal/format/zip/zip.go b/internal/format/zip/zip.go index 004943d3..acf4dde1 100644 --- a/internal/format/zip/zip.go +++ b/internal/format/zip/zip.go @@ -174,6 +174,7 @@ func (generator) PlanContext(ctx context.Context, r format.Request) (format.Plan } p.Memo = m + p.Holds = format.OneAtATime(m.children, func(c child) format.Plan { return c.plan }) return p, nil } diff --git a/internal/guard/catalogue_test.go b/internal/guard/catalogue_test.go index 761bf61e..888a0e5a 100644 --- a/internal/guard/catalogue_test.go +++ b/internal/guard/catalogue_test.go @@ -43,8 +43,7 @@ func englishInTheCode(t *testing.T) (map[string]string, map[string]string) { // The singular of every plural message, kept apart because only some // messages have one. singular := map[string]string{} - for _, name := range []string{"screens.go", "text.go"} { - path := filepath.Join("../gui/text", name) + for _, path := range textPackageFiles(t) { file, err := parser.ParseFile(token.NewFileSet(), path, nil, 0) if err != nil { t.Fatalf("%s: %v", path, err) @@ -265,3 +264,28 @@ func TestEveryTranslationObeysThePunctuationRule(t *testing.T) { } } } + +// textPackageFiles is every file of internal/gui/text but its tests. +// +// Read off the directory rather than named. Both guards that read the package +// named its files by hand until 2026-10-27, and the words of the Preferences +// screen moved into a file of their own that day: the catalogue guard would +// have reported them as orphans, and the guard that every sentence goes +// through the catalogue would not have read them at all (CLAUDE.md, trap 15). +func textPackageFiles(t *testing.T) []string { + t.Helper() + all, err := filepath.Glob(filepath.Join("../gui/text", "*.go")) + if err != nil { + t.Fatal(err) + } + var out []string + for _, path := range all { + if !strings.HasSuffix(path, "_test.go") { + out = append(out, path) + } + } + if len(out) < 3 { + t.Fatalf("internal/gui/text holds %d files, so this read the wrong directory", len(out)) + } + return out +} diff --git a/internal/guard/cataloguecoverage_test.go b/internal/guard/cataloguecoverage_test.go index 192340a3..c3e80d2c 100644 --- a/internal/guard/cataloguecoverage_test.go +++ b/internal/guard/cataloguecoverage_test.go @@ -98,7 +98,13 @@ func wordsIn(node ast.Node) []string { func TestEverySentenceInTheTextPackageGoesThroughTheCatalogue(t *testing.T) { checked, offenders := 0, map[string][]string{} - for _, name := range []string{"screens.go", "text.go", "labels.go"} { + // Named rather than read off the directory, unlike englishInTheCode: the + // other files of the package hold text that is not language - formats, + // a byte count's mark - and reading them asks this guard about strings it + // was never meant for. preferences.go joined on 2026-10-27, when the words + // of the Preferences screen moved out of screens.go. A file of sentences + // added later has to join here by hand, and that is O292. + for _, name := range []string{"screens.go", "text.go", "labels.go", "preferences.go"} { path := filepath.Join("../gui/text", name) file, err := parser.ParseFile(token.NewFileSet(), path, nil, 0) if err != nil { diff --git a/internal/guard/cgrouplimit_test.go b/internal/guard/cgrouplimit_test.go new file mode 100644 index 00000000..4aa4e01a --- /dev/null +++ b/internal/guard/cgrouplimit_test.go @@ -0,0 +1,250 @@ +package guard + +import ( + "io/fs" + "testing" + + "github.com/donislawdev/TestingFilesGenerator/internal/core" +) + +// The memory limit of a container is found in the files Linux keeps about it - +// docs/STABILITY-REVIEW-2026-10-26.md section 10.8. +// +// Every layout below is one a process really meets, handed to the reader as +// files, so the table runs on every system rather than only on the Linux +// runner - and only on the layouts somebody wrote down, which is why the real +// reading has a guard of its own beside this one. The shapes are the ones +// docs.kernel.org (cgroup-v2, read 2026-10-27) and proc_pid_mountinfo(5) +// describe: "0::$PATH" for version 2, "N:controllers:$PATH" for version 1, a +// mount root that is the group itself inside a namespace. +func TestTheLimitOfAContainerIsFoundInItsControlGroup(t *testing.T) { + for _, c := range containerLayouts { + read := func(name string) ([]byte, error) { + if text, ok := c.files[name]; ok { + return []byte(text), nil + } + return nil, &fs.PathError{Op: "open", Path: name, Err: fs.ErrNotExist} + } + limit, limited, err := core.CgroupMemoryLimit(read) + switch { + case c.fails && err == nil: + t.Errorf("%s: read as limit %d limited %v, expected a refusal - a number made up from a file that does not say one is a limit nobody set", c.name, limit, limited) + case !c.fails && err != nil: + t.Errorf("%s: refused: %v", c.name, err) + case !c.fails && (limit != c.limit || limited != c.limited): + t.Errorf("%s: limit %d limited %v, expected %d %v", c.name, limit, limited, c.limit, c.limited) + } + } +} + +// Two lines of mountinfo most layouts below share: the one cgroup2 mount +// where systemd and every container runtime put it, and a filesystem that is +// not a control group at all, which the reader has to step over. +const ( + cgroupV2Mount = "30 23 0:26 / /sys/fs/cgroup rw,nosuid,nodev,noexec,relatime shared:4 - cgroup2 cgroup2 rw,nsdelegate\n" + cgroupOtherMount = "22 1 8:1 / / rw,relatime shared:1 - ext4 /dev/sda1 rw\n" +) + +// containerLayouts is every layout the guard above hands the reader, with the +// answer it has to give. +var containerLayouts = []struct { + name string + files map[string]string + limit int64 + limited bool + fails bool +}{ + { + name: "a container with its own namespace, 512 MiB", + files: map[string]string{ + "/proc/self/cgroup": "0::/\n", + "/proc/self/mountinfo": cgroupOtherMount + cgroupV2Mount, + "/sys/fs/cgroup/memory.max": "536870912\n", + }, + limit: 512 << 20, limited: true, + }, + { + name: "a container with no limit", + files: map[string]string{ + "/proc/self/cgroup": "0::/\n", + "/proc/self/mountinfo": cgroupV2Mount, + "/sys/fs/cgroup/memory.max": "max\n", + }, + }, + { + name: "a desktop session, max on its own group and a limit on the slice above it", + files: map[string]string{ + "/proc/self/cgroup": "0::/user.slice/user-1000.slice/session-2.scope\n", + "/proc/self/mountinfo": cgroupV2Mount, + "/sys/fs/cgroup/user.slice/user-1000.slice/session-2.scope/memory.max": "max\n", + "/sys/fs/cgroup/user.slice/user-1000.slice/memory.max": "4294967296\n", + "/sys/fs/cgroup/user.slice/memory.max": "max\n", + }, + limit: 4 << 30, limited: true, + }, + { + name: "the smaller of two limits on the way up wins", + files: map[string]string{ + "/proc/self/cgroup": "0::/a/b\n", + "/proc/self/mountinfo": cgroupV2Mount, + "/sys/fs/cgroup/a/b/memory.max": "8589934592\n", + "/sys/fs/cgroup/a/memory.max": "1073741824\n", + }, + limit: 1 << 30, limited: true, + }, + { + name: "a group removed while the process is still in it", + files: map[string]string{ + "/proc/self/cgroup": "0::/job (deleted)\n", + "/proc/self/mountinfo": cgroupV2Mount, + "/sys/fs/cgroup/job/memory.max": "268435456\n", + }, + limit: 256 << 20, limited: true, + }, + { + name: "the root group, which has no memory.max at all", + files: map[string]string{ + "/proc/self/cgroup": "0::/\n", + "/proc/self/mountinfo": cgroupV2Mount, + }, + }, + { + name: "a mount point with a space in it, escaped the way Linux writes it", + files: map[string]string{ + "/proc/self/cgroup": "0::/\n", + "/proc/self/mountinfo": `31 23 0:27 / /mnt/cg\040two rw - cgroup2 cgroup2 rw` + "\n", + "/mnt/cg two/memory.max": "1048576\n", + }, + limit: 1 << 20, limited: true, + }, + { + name: "version 1, the memory controller beside others", + files: map[string]string{ + "/proc/self/cgroup": "12:pids:/docker/abc\n4:memory:/docker/abc\n1:name=systemd:/docker/abc\n0::/system.slice/docker.service\n", + "/proc/self/mountinfo": cgroupOtherMount + + "40 30 0:35 /docker/abc /sys/fs/cgroup/memory ro,nosuid - cgroup cgroup rw,memory\n" + + "41 30 0:36 /docker/abc /sys/fs/cgroup/pids ro,nosuid - cgroup cgroup rw,pids\n", + "/sys/fs/cgroup/memory/memory.limit_in_bytes": "2147483648\n", + }, + limit: 2 << 30, limited: true, + }, + { + name: "version 1 with no limit, which it writes as an enormous number", + files: map[string]string{ + "/proc/self/cgroup": "4:cpuset,memory:/\n", + "/proc/self/mountinfo": "40 30 0:35 / /sys/fs/cgroup/memory rw - cgroup cgroup rw,cpuset,memory\n", + "/sys/fs/cgroup/memory/memory.limit_in_bytes": "9223372036854771712\n", + }, + limit: 9223372036854771712, limited: true, + }, + { + // A systemd host on the old layout: the session's own group has no + // limit, the user's slice above it has. Version 1 accounts up the + // hierarchy as version 2 does, so the slice holds the session. + name: "version 1, no limit on its own group and a limit on the slice above it", + files: map[string]string{ + "/proc/self/cgroup": "9:memory:/user.slice/user-1000.slice/session-2.scope\n", + "/proc/self/mountinfo": "40 30 0:35 / /sys/fs/cgroup/memory rw,nosuid - cgroup cgroup rw,memory\n", + "/sys/fs/cgroup/memory/user.slice/user-1000.slice/session-2.scope/memory.limit_in_bytes": "9223372036854771712\n", + "/sys/fs/cgroup/memory/user.slice/user-1000.slice/memory.limit_in_bytes": "4294967296\n", + "/sys/fs/cgroup/memory/user.slice/memory.limit_in_bytes": "9223372036854771712\n", + "/sys/fs/cgroup/memory/memory.limit_in_bytes": "9223372036854771712\n", + }, + limit: 4 << 30, limited: true, + }, + { + // Linux writes no group with "." or ".." in it past the run that says + // it is outside the namespace. Joined to the mount, this one walks out + // of it to a file that holds a limit nobody set on this process. + name: "a group that would walk out of its mount", + files: map[string]string{ + "/proc/self/cgroup": "0::/a/../../etc\n", + "/proc/self/mountinfo": cgroupV2Mount, + "/sys/fs/etc/memory.max": "1048576\n", + "/sys/fs/cgroup/memory.max": "max\n", + }, + fails: true, + }, + { + name: "a group with a step that goes nowhere", + files: map[string]string{ + "/proc/self/cgroup": "4:memory:/docker/./abc\n", + "/proc/self/mountinfo": "40 30 0:35 / /sys/fs/cgroup/memory rw - cgroup cgroup rw,memory\n", + "/sys/fs/cgroup/memory/docker/abc/memory.limit_in_bytes": "1048576\n", + }, + fails: true, + }, + { + name: "a mount whose root does not reach the group", + files: map[string]string{ + "/proc/self/cgroup": "0::/elsewhere/job\n", + "/proc/self/mountinfo": "30 23 0:26 /sandbox /sys/fs/cgroup rw - cgroup2 cgroup2 rw\n", + }, + }, + { + name: "a root that only shares the start of the name", + files: map[string]string{ + "/proc/self/cgroup": "0::/sandbox2/job\n", + "/proc/self/mountinfo": "30 23 0:26 /sandbox /sys/fs/cgroup rw - cgroup2 cgroup2 rw\n", + }, + }, + { + name: "a group outside the namespace this process can see", + files: map[string]string{ + "/proc/self/cgroup": "0::/../../init\n", + "/proc/self/mountinfo": cgroupV2Mount, + }, + }, + { + name: "no control groups at all", + files: map[string]string{}, + }, + { + name: "no cgroup2 mount to read it through", + files: map[string]string{ + "/proc/self/cgroup": "0::/job\n", + "/proc/self/mountinfo": cgroupOtherMount, + }, + }, + { + name: "a limit file that says something that is not a number", + files: map[string]string{ + "/proc/self/cgroup": "0::/\n", + "/proc/self/mountinfo": cgroupV2Mount, + "/sys/fs/cgroup/memory.max": "lots\n", + }, + fails: true, + }, + { + name: "a negative limit", + files: map[string]string{ + "/proc/self/cgroup": "0::/\n", + "/proc/self/mountinfo": cgroupV2Mount, + "/sys/fs/cgroup/memory.max": "-1\n", + }, + fails: true, + }, + { + name: "a line of /proc/self/cgroup with no path", + files: map[string]string{ + "/proc/self/cgroup": "0::\n", + }, + fails: true, + }, + { + name: "a mountinfo line with no separator", + files: map[string]string{ + "/proc/self/cgroup": "0::/\n", + "/proc/self/mountinfo": "30 23 0:26 / /sys/fs/cgroup rw cgroup2 cgroup2 rw\n", + }, + fails: true, + }, + { + name: "an escape cut short at the end of the mount point", + files: map[string]string{ + "/proc/self/cgroup": "0::/\n", + "/proc/self/mountinfo": `30 23 0:26 / /mnt/x\04 rw - cgroup2 cgroup2 rw` + "\n", + }, + fails: true, + }, +} diff --git a/internal/guard/concurrency_test.go b/internal/guard/concurrency_test.go index a279e3d0..3388d3aa 100644 --- a/internal/guard/concurrency_test.go +++ b/internal/guard/concurrency_test.go @@ -266,6 +266,12 @@ func concurrentName(node *ast.SelectorExpr) string { return "uses " + id.Name + "." + node.Sel.Name case id.Name == "time" && node.Sel.Name == "AfterFunc": return "hands a callback to a timer's goroutine (time.AfterFunc)" + case id.Name == "context" && node.Sel.Name == "AfterFunc": + // The same shape as the timer's, found on 2026-10-27 when the memory + // queue in internal/engine/parallel.go took one: the callback runs on + // a goroutine of its own when the context ends, and the scan had no + // name for it. + return "hands a callback to the goroutine a context's end starts (context.AfterFunc)" } return "" } @@ -279,6 +285,7 @@ func TestTheConcurrencyScanSeesEachKindItLooksFor(t *testing.T) { {"a channel", `var c chan int; _ = c`}, {"a lock", `var m sync.Mutex; m.Lock()`}, {"a timer's callback", `time.AfterFunc(0, f)`}, + {"a context's callback", `context.AfterFunc(ctx, f)`}, } { src := "package x\nfunc f() {}\nfunc g() {\n" + c.body + "\n}\n" fset := token.NewFileSet() diff --git a/internal/guard/guitext_test.go b/internal/guard/guitext_test.go index 012cf861..a2edaed2 100644 --- a/internal/guard/guitext_test.go +++ b/internal/guard/guitext_test.go @@ -95,6 +95,8 @@ var notWords = map[string]string{ `"windowHeight"`: "the name the window files its height under, never shown", `"language"`: "the name the window files the chosen language under, never shown. " + "Translating it would lose the choice the day somebody changed language", + `"memory"`: "the name the window files the chosen memory setting under, never shown. " + + "Translating it would lose the choice the day somebody changed language", `"--pseudo-language"`: "a flag on the launch line for whoever builds the window, English by D9 like every flag, never translated or shown", // What each screen IS, as opposed to the word on its tab - see parts.Tab. // They carry the output directory and the keyboard between screens, so a diff --git a/internal/guard/holds_test.go b/internal/guard/holds_test.go new file mode 100644 index 00000000..89c170d9 --- /dev/null +++ b/internal/guard/holds_test.go @@ -0,0 +1,187 @@ +package guard + +import ( + "context" + "errors" + "io" + "runtime" + "strconv" + "testing" + + "github.com/donislawdev/TestingFilesGenerator/internal/format" + _ "github.com/donislawdev/TestingFilesGenerator/internal/format/all" + pic "github.com/donislawdev/TestingFilesGenerator/internal/format/picture" +) + +// Every format whose writing holds more memory for a larger picture says so in +// its plan - docs/STABILITY-REVIEW-2026-10-26.md sections 10.4 and 10.8. +// +// The memory setting holds the writers back by what their files declare +// (format.Plan.Holds), so a format that forgets to declare is a format the +// setting cannot see: sixteen of its pictures go out at once whatever the +// setting says, which is R02 back again for that one format. +// +// Asked of every format with a width and a height, from the registry, so a +// format added later is asked without being named here. The question is put +// to what writing allocates rather than to the code: the same file planned +// twice, the second picture with twice the pixels, and the bytes allocated +// while each is written. A format whose writing allocates more for the larger +// picture holds the picture, and has to declare more than nothing and more for +// the larger one. A format that streams allocates the same for both and may +// declare nothing - bmp, tiff and webp do, and so do jxl and avif with a size +// named, because they code the picture while planning. +// +// The allocation is a bound from above on what is held, not a measure of it, +// so this cannot say a declaration is too small - only that one is missing or +// does not grow. The numbers themselves are measured in the comment beside +// each. +func TestEveryPictureSaysWhatWritingItHolds(t *testing.T) { + const w, h = 128, 96 + growing := map[string]bool{} + for _, d := range format.All() { + if !takes(d, "width") || !takes(d, "height") { + continue + } + small, smallAlloc := writtenAt(t, d, w, h) + large, largeAlloc := writtenAt(t, d, 2*w, h) + if largeAlloc < smallAlloc+uint64(w*h) { + continue + } + growing[d.ID] = true + if small.Holds <= 0 || large.Holds <= small.Holds { + t.Errorf("%s allocates %d B writing a %dx%d picture and %d B writing one twice as wide, so writing it holds the picture - and it declares %d B and %d B. The memory setting cannot hold back what a plan does not declare", + d.ID, smallAlloc, w, h, largeAlloc, small.Holds, large.Holds) + } + } + // The three that hold their picture while writing, measured on + // 2026-10-27 - if the instrument stops seeing them, it is the instrument + // that broke, and the guard says so rather than passing on nothing. + for _, id := range []string{"jpg", "png", "gif"} { + if !growing[id] { + t.Errorf("%s did not allocate more for a larger picture, so this measurement no longer sees a format holding its picture", id) + } + } + t.Logf("formats whose writing grows with the picture: %v", growing) +} + +// The same question with no size named, where the format chooses the picture. +// +// jxl and avif choose from a ladder without coding anything while planning, +// and code the picture while writing - so the declaration that matters for +// them is on the path the guard above never takes, since it names a size. +// Doubling the pixels cannot be asked here, because the format picks them, so +// the question is the plainer one: writing allocated at least a byte for each +// pixel of the picture it chose, and then the plan has to declare something. +func TestEveryPictureItChoosesSaysWhatWritingItHolds(t *testing.T) { + coded := map[string]bool{} + for _, d := range format.All() { + if !takes(d, "width") || !takes(d, "height") { + continue + } + p, alloc := written(t, d, format.Request{Bytes: 1 << 20, Seed: 1}) + w, _ := p.Properties["width"].(int) + h, _ := p.Properties["height"].(int) + if w*h == 0 || alloc < uint64(w*h) { + continue + } + coded[d.ID] = true + if p.Holds <= 0 { + t.Errorf("%s chose a %dx%d picture and allocated %d B writing it, and declares it holds %d B", d.ID, w, h, alloc, p.Holds) + } + } + for _, id := range []string{"jxl", "avif"} { + if !coded[id] { + t.Errorf("%s with no size named did not allocate a byte a pixel while writing, so this no longer reaches the picture it codes then", id) + } + } + t.Logf("formats that hold the picture they chose: %v", coded) +} + +// A container holds what the largest picture inside it holds, because it +// writes them one after another and codes each while it writes. +func TestAContainerHoldsWhatItsLargestPictureHolds(t *testing.T) { + png := planned(t, "png", format.Request{Bytes: 64 << 10, Seed: 1}) + if png.Holds <= 0 { + t.Fatalf("a png declares it holds %d B, so there is nothing for a container to pass up", png.Holds) + } + inside := []format.Content{{Format: "txt", Count: 2, Bytes: 1024}, {Format: "png", Count: 2, Bytes: 64 << 10}} + for _, id := range []string{"zip", "targz"} { + p := planned(t, id, format.Request{Bytes: 1 << 20, Seed: 1, Contains: inside}) + if p.Holds != png.Holds { + t.Errorf("%s holding two pngs of %d B declares %d B, and each png declares %d B - a container writes them one at a time, so it holds the largest of them", + id, 64<<10, p.Holds, png.Holds) + } + } + pages := 0 + for _, d := range format.All() { + if !takes(d, pic.Count) { + continue + } + pages++ + p := planned(t, d.ID, format.Request{Bytes: 1 << 20, Seed: 1, Properties: map[string]string{pic.Count: "3", "inline_size": "16kb"}}) + if p.Holds <= 0 { + t.Errorf("%s with three pictures in it declares %d B, though it codes each picture while it writes", d.ID, p.Holds) + } + } + if pages == 0 { + t.Fatal("no format takes inline pictures, so the second half of this guard asked nothing") + } +} + +// takes says whether a format has a setting of this name. +func takes(d format.Descriptor, name string) bool { + for _, p := range d.Properties { + if p.Name == name { + return true + } + } + return false +} + +// planned is a plan of one file, or the end of the test. +func planned(t *testing.T, id string, r format.Request) format.Plan { + t.Helper() + d, err := format.Get(id) + if err != nil { + t.Fatal(err) + } + p, err := format.PlanWith(context.Background(), d.Generator, r) + if err != nil { + t.Fatalf("planning %s: %v", id, err) + } + return p +} + +// writtenAt is written with the picture's width and height named. +func writtenAt(t *testing.T, d format.Descriptor, w, h int) (format.Plan, uint64) { + t.Helper() + return written(t, d, format.Request{Bytes: 1 << 20, Seed: 1, Properties: map[string]string{"width": strconv.Itoa(w), "height": strconv.Itoa(h)}}) +} + +// written plans one file of the request at a size the format can hold, writes +// it, and says what the plan declared and what writing allocated. +func written(t *testing.T, d format.Descriptor, r format.Request) (format.Plan, uint64) { + t.Helper() + p, err := format.PlanWith(context.Background(), d.Generator, r) + var below *format.BelowMinimumError + if errors.As(err, &below) { + r.Bytes = below.Minimum + p, err = format.PlanWith(context.Background(), d.Generator, r) + } + var above *format.AboveMaximumError + if errors.As(err, &above) { + r.Bytes = above.Maximum + p, err = format.PlanWith(context.Background(), d.Generator, r) + } + if err != nil { + t.Fatalf("planning a %s of %v: %v", d.ID, r.Properties, err) + } + runtime.GC() + var before, after runtime.MemStats + runtime.ReadMemStats(&before) + if err := d.Generator.Write(context.Background(), io.Discard, p); err != nil { + t.Fatalf("writing a %s of %v: %v", d.ID, r.Properties, err) + } + runtime.ReadMemStats(&after) + return p, after.TotalAlloc - before.TotalAlloc +} diff --git a/internal/guard/machinememory_test.go b/internal/guard/machinememory_test.go new file mode 100644 index 00000000..a3da8598 --- /dev/null +++ b/internal/guard/machinememory_test.go @@ -0,0 +1,107 @@ +package guard + +import ( + "bufio" + "context" + "os" + "os/exec" + "runtime" + "strconv" + "strings" + "testing" + "time" + + "github.com/donislawdev/TestingFilesGenerator/internal/core" +) + +// The memory this program reads off the machine is the memory a second, +// independent source reports - docs/STABILITY-REVIEW-2026-10-26.md section +// 10.8. +// +// Asked of the system on whichever of the three CI runs on, and checked +// against something that is not this code: /proc/meminfo on Linux, the +// system's management interface on Windows, the sysctl command on macOS. A +// read of the wrong field of the right structure, a structure laid out one +// field off, or the last byte lost on macOS - each gives a number, and only a +// second source can say it is the wrong one. +// +// The branch is asserted rather than assumed (CLAUDE.md, trap 1). On Linux the +// answer is the memory fitted or the limit of the control group, whichever is +// smaller, and the test says which it expected. On Windows the commit limit of +// a job could make the answer smaller than the memory fitted - a runner placed +// in such a job would be a surprise, and this says so rather than passing. +func TestTheMemoryOfTheMachineIsWhatTheSystemSays(t *testing.T) { + got, err := core.MachineMemory() + if err != nil { + t.Fatalf("the memory of this machine could not be read: %v", err) + } + + switch runtime.GOOS { + case "linux": + fitted := memTotal(t) + limit, limited, err := core.CgroupMemoryLimit(os.ReadFile) + if err != nil { + t.Fatalf("the control group of this process could not be read: %v", err) + } + want, branch := fitted, "the memory fitted" + if limited && limit < fitted { + want, branch = limit, "the limit of the control group" + } + if got != want { + t.Errorf("read %d B, expected %d B - %s (MemTotal %d B, group limit %d B, limited %v)", got, want, branch, fitted, limit, limited) + } + t.Logf("%d B, %s", got, branch) + case "windows": + visible := secondOpinion(t, "pwsh", "-NoProfile", "-NonInteractive", "-Command", + "(Get-CimInstance Win32_OperatingSystem).TotalVisibleMemorySize") * 1024 + if got != visible { + t.Errorf("read %d B and the management interface says %d B of memory is fitted - the wrong field, a structure out of step, or a commit limit on this process", got, visible) + } + case "darwin": + fitted := secondOpinion(t, "sysctl", "-n", "hw.memsize") + if got != fitted { + t.Errorf("read %d B and sysctl says %d B - the byte syscall.Sysctl drops is the first suspect", got, fitted) + } + default: + t.Skipf("no second source for %s, and CI does not run there", runtime.GOOS) + } +} + +// memTotal is MemTotal from /proc/meminfo, in bytes. +func memTotal(t *testing.T) int64 { + t.Helper() + f, err := os.Open("/proc/meminfo") + if err != nil { + t.Fatal(err) + } + defer func() { _ = f.Close() }() + lines := bufio.NewScanner(f) + for lines.Scan() { + fields := strings.Fields(lines.Text()) + if len(fields) == 3 && fields[0] == "MemTotal:" && fields[2] == "kB" { + n, err := strconv.ParseInt(fields[1], 10, 64) + if err != nil { + t.Fatal(err) + } + return n * 1024 + } + } + t.Fatal("/proc/meminfo has no MemTotal line in kB") + return 0 +} + +// secondOpinion runs a command that prints one number and reads it. +func secondOpinion(t *testing.T, name string, args ...string) int64 { + t.Helper() + ctx, cancel := context.WithTimeout(context.Background(), time.Minute) + defer cancel() + out, err := exec.CommandContext(ctx, name, args...).Output() + if err != nil { + t.Fatalf("%s: %v", name, err) + } + n, err := strconv.ParseInt(strings.TrimSpace(string(out)), 10, 64) + if err != nil { + t.Fatalf("%s printed %q, not a number", name, out) + } + return n +} diff --git a/internal/guard/memoryflag_test.go b/internal/guard/memoryflag_test.go new file mode 100644 index 00000000..5e5b9d3f --- /dev/null +++ b/internal/guard/memoryflag_test.go @@ -0,0 +1,110 @@ +package guard + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/donislawdev/TestingFilesGenerator/internal/cli" + "github.com/donislawdev/TestingFilesGenerator/internal/core" +) + +// --memory is read the same way on every command that plans - generate, +// validate and preset show - docs/STABILITY-REVIEW-2026-10-26.md section 10.8. +// +// One function registers it on all three, and this is what says each of them +// really hands it to the planner: a ceiling far under the plan refuses the plan +// on each, and the refusal names the setting. The plan is about 42 MB of text +// files on the first two and about 9 MB of the largest preset on the third, +// against ceilings of 4 MB and 1 MB, so the margin is the one +// TestAPlanTooBigToHoldIsRefusedWhileItIsBuilt measured as clear of the noise a +// heap reading carries in this process. The help is asked too, and preset show +// is why: its help is written out by hand rather than listed by the flag +// package, so a flag registered there is a flag nobody is told about unless +// the sentence is added beside it. +func TestTheMemoryFlagReachesThePlanOnEveryCommandThatPlans(t *testing.T) { + dir := t.TempDir() + recipe := filepath.Join(dir, "many.yaml") + if err := os.WriteFile(recipe, []byte("version: 1\ntargets:\n - id: many\n format: txt\n size: 4kb\n count: 50000\n"), 0o644); err != nil { + t.Fatal(err) + } + out := filepath.Join(dir, "out") + for _, c := range []struct { + name string + args []string + ceiling string + }{ + {"generate", []string{"generate", "--format", "txt", "--size", "4kb", "--count", "50000", "--dry-run", "--out", out}, "4mb"}, + {"validate", []string{"validate", recipe}, "4mb"}, + // Text files in the mass upload rather than the pictures it holds by + // default, which are coded while planning and take a minute for ten + // thousand on main as on this branch (2026-10-27). + {"preset show", []string{"preset", "show", "upload-validation", "--allow", "txt", "--bulk", "10000"}, "1mb"}, + } { + code, _, errOut := run(t, append(c.args, "--memory", c.ceiling)...) + if code != cli.ExitRecipe || !strings.Contains(errOut, "the memory setting of this run gives it") { + t.Errorf("%s --memory %s: exit %d, expected %d and a refusal naming the memory setting:\n%s", c.name, c.ceiling, code, cli.ExitRecipe, errOut) + } + // The same command without the flag is auto, at least two gigabytes + // of plan, and the plan goes through - so the refusal above was the + // flag's doing rather than the shape's. + if code, _, errOut := run(t, c.args...); code != cli.ExitOK { + t.Errorf("%s with no --memory: exit %d, expected the plan to pass under auto:\n%s", c.name, code, errOut) + } + + // A value that is not a setting is the caller's typing, exit 2 with + // the sentence of the parser, on every one of the three. + code, _, errOut = run(t, append(c.args, "--memory", "0")...) + if code != cli.ExitUsage || !strings.Contains(errOut, "leaves no room at all") { + t.Errorf("%s --memory 0: exit %d, expected %d and why nought is refused:\n%s", c.name, code, cli.ExitUsage, errOut) + } + + // And the help says the flag is there, and what auto means - with + // the floor the plan keeps under it, or a machine of four gigabytes + // or less reads a refusal the run would never give (review on #208). + _, help, _ := run(t, append(c.args[:len(c.args):len(c.args)], "--help")...) + floor := "a plan of up to " + core.HumanBytes(core.MaxPlanBytes) + " is never refused" + if !strings.Contains(help, "-memory") || !strings.Contains(help, "half of what this machine gives the program") || + !strings.Contains(help, floor) { + t.Errorf("%s --help does not offer --memory and say what auto is, floor included:\n%s", c.name, help) + } + } +} + +// A file written past the memory setting is said on the command line, once. +// +// The engine owes the note (TestTheFilesWrittenAtOnceHoldNoMoreThanTheMemorySettingLeaves +// asks it of the result), and this asks that the command line passes it on: +// the note lives beside the manifest rather than in it, so the loop that +// prints the manifest's notes does not print it. Three jpgs of three +// megapixels declare about fourteen megabytes each against eight of setting. +// The same three files with no setting at all are byte for byte the same - +// the queue changes when a file is written, never what is in it. +func TestAFileWrittenPastTheMemorySettingIsSaidOnTheCommandLine(t *testing.T) { + dir := t.TempDir() + shape := []string{"generate", "--format", "jpg", "--size", "4mb", "--set", "width=2000", "--set", "height=1500", "--count", "3"} + code, _, errOut := run(t, append(shape, "--memory", "8mb", "--out", filepath.Join(dir, "held"))...) + if code != cli.ExitOK { + t.Fatalf("exit %d:\n%s", code, errOut) + } + if n := strings.Count(errOut, "note: files_0001.jpg holds about"); n != 1 { + t.Errorf("the note about the file written past the setting was printed %d times, expected once:\n%s", n, errOut) + } + if code, _, errOut := run(t, append(shape, "--out", filepath.Join(dir, "auto"))...); code != cli.ExitOK { + t.Fatalf("exit %d:\n%s", code, errOut) + } + for _, name := range []string{"files_0001.jpg", "files_0002.jpg", "files_0003.jpg"} { + held, err := os.ReadFile(filepath.Join(dir, "held", name)) + if err != nil { + t.Fatal(err) + } + auto, err := os.ReadFile(filepath.Join(dir, "auto", name)) + if err != nil { + t.Fatal(err) + } + if string(held) != string(auto) { + t.Errorf("%s came out different under --memory 8mb - the setting moved a byte", name) + } + } +} diff --git a/internal/guard/memoryqueue_test.go b/internal/guard/memoryqueue_test.go new file mode 100644 index 00000000..d486d3bb --- /dev/null +++ b/internal/guard/memoryqueue_test.go @@ -0,0 +1,177 @@ +package guard + +import ( + "context" + "errors" + "io" + "runtime" + "strings" + "sync" + "testing" + "time" + + "github.com/donislawdev/TestingFilesGenerator/internal/core" + "github.com/donislawdev/TestingFilesGenerator/internal/engine" + "github.com/donislawdev/TestingFilesGenerator/internal/format" +) + +// The files written at once hold no more than the memory setting leaves them, +// and the queue that keeps them there never refuses, never stalls a file that +// holds nothing and never starts a file after a stop - +// docs/STABILITY-REVIEW-2026-10-26.md sections 10.1, 10.4 and 10.8. +// +// Nothing here allocates the memory it talks about. What a file holds is its +// declaration (format.Plan.Holds), so the plan of four kilobyte text files is +// given declarations of a gigabyte or four and the queue is watched through a +// generator that counts the writers inside it. The room is set from the heap +// as it stands, so the test process's own few megabytes do not move the +// answer: two files of a gigabyte fit in two and a half, three do not. +// +// Four writers whatever the machine, so the answer does not depend on how many +// processors the runner has. +func TestTheFilesWrittenAtOnceHoldNoMoreThanTheMemorySettingLeaves(t *testing.T) { + was := runtime.GOMAXPROCS(4) + t.Cleanup(func() { runtime.GOMAXPROCS(was) }) + const gib = int64(1) << 30 + + t.Run("two fit, and two are written at once", func(t *testing.T) { + c := &crowd{waitForSecond: true} + res, err := runCrowded(t, context.Background(), c, []int64{gib, gib, gib, gib, gib, gib}, 5*gib/2) + if err != nil { + t.Fatal(err) + } + if c.most > 2 { + t.Errorf("%d files of a gigabyte were written at once in two and a half gigabytes of room", c.most) + } + if c.most < 2 { + t.Errorf("only one file was written at a time with room for two - the queue holds the writers back further than the setting asks") + } + if len(res.RunNotes) != 0 { + t.Errorf("a run that never went past the setting says it did: %v", res.RunNotes) + } + }) + + t.Run("a file larger than all the room goes on its own and says so", func(t *testing.T) { + c := &crowd{} + res, err := runCrowded(t, context.Background(), c, []int64{4 * gib, 4 * gib, 4 * gib}, 5*gib/2) + if err != nil { + t.Fatal(err) + } + if c.calls != 3 { + t.Fatalf("%d of three files were written - a file larger than the room has to be written, not refused and not left waiting", c.calls) + } + if c.most != 1 { + t.Errorf("%d files larger than the room were written at once", c.most) + } + if len(res.RunNotes) != 1 || !strings.Contains(res.RunNotes[0].String(), "files_0001.txt") { + t.Errorf("the run went past the setting and did not say so once, naming the first file: %v", res.RunNotes) + } + }) + + t.Run("a file that holds nothing does not wait", func(t *testing.T) { + c := &crowd{waitForSecond: true} + if _, err := runCrowded(t, context.Background(), c, []int64{4 * gib, 0, 0, 0}, 5*gib/2); err != nil { + t.Fatal(err) + } + if c.most < 2 { + t.Errorf("text files waited behind a picture written on its own, though they hold no memory at all") + } + }) + + t.Run("a stop while files wait starts none of them", func(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + c := &crowd{stopFirst: cancel} + _, err := runCrowded(t, ctx, c, []int64{4 * gib, 4 * gib, 4 * gib, 4 * gib}, 5*gib/2) + if !errors.Is(err, context.Canceled) { + t.Errorf("the run ended with %v rather than the stop", err) + } + if c.calls != 1 { + t.Errorf("%d files were started - the ones waiting for room began writing after the run was stopped", c.calls) + } + }) +} + +// runCrowded plans one text file per declaration, gives each its declaration +// and the counting generator, and runs it with room bytes left for the writers. +func runCrowded(t *testing.T, ctx context.Context, c *crowd, holds []int64, room int64) (*engine.Result, error) { + t.Helper() + opt := engine.Options{OutDir: t.TempDir(), Seed: 7, Command: "test"} + planned, err := engine.Plan([]engine.Target{{ID: "files", Format: "txt", Sizes: engine.Uniform(len(holds), 4096)}}, opt) + if err != nil { + t.Fatal(err) + } + for i := range planned { + planned[i].Plan.Holds = holds[i] + planned[i].Desc.Generator = crowdedGenerator{Generator: planned[i].Desc.Generator, c: c} + } + // The limit is the room on top of the heap as it is now, read the way the + // engine reads it when it has to be exact. + runtime.GC() + var m runtime.MemStats + runtime.ReadMemStats(&m) + opt.Memory = core.MemoryOf(int64(m.HeapAlloc) + room) + return engine.Run(ctx, planned, opt) +} + +// crowd counts the writers inside the generator: how many now, the most at +// once, and how many files were started at all. +type crowd struct { + mu sync.Mutex + now, most, calls int + waitForSecond bool + stopFirst func() +} + +// crowdedGenerator is the real generator with a count around it. +type crowdedGenerator struct { + format.Generator + c *crowd +} + +func (g crowdedGenerator) Write(ctx context.Context, w io.Writer, p format.Plan) error { + first := g.c.enter() + defer g.c.leave() + if first && g.c.stopFirst != nil { + g.c.stopFirst() + return ctx.Err() + } + // The first file waits for a second writer to join it, for at most a few + // seconds. A queue that lets two in is answered in milliseconds. One that + // lets only one in is answered by the deadline - the clock bounds a wait + // here, it does not measure anything. + if first && g.c.waitForSecond { + g.c.waitForAnother(5 * time.Second) + } + return g.Generator.Write(ctx, w, p) +} + +func (c *crowd) enter() bool { + c.mu.Lock() + defer c.mu.Unlock() + c.now++ + c.calls++ + c.most = max(c.most, c.now) + return c.calls == 1 +} + +func (c *crowd) leave() { + c.mu.Lock() + defer c.mu.Unlock() + c.now-- +} + +func (c *crowd) waitForAnother(limit time.Duration) { + deadline := time.Now().Add(limit) + for time.Now().Before(deadline) { + c.mu.Lock() + // The most at once rather than the number now: a text file is in + // and out in microseconds, between two looks a millisecond apart. + joined := c.most >= 2 + c.mu.Unlock() + if joined { + return + } + time.Sleep(time.Millisecond) + } +} diff --git a/internal/guard/memorysetting_test.go b/internal/guard/memorysetting_test.go new file mode 100644 index 00000000..92cbd083 --- /dev/null +++ b/internal/guard/memorysetting_test.go @@ -0,0 +1,169 @@ +package guard + +import ( + "errors" + "math" + "strings" + "testing" + + "github.com/donislawdev/TestingFilesGenerator/internal/core" +) + +// The memory setting reads what somebody wrote and nothing else - +// docs/STABILITY-REVIEW-2026-10-26.md section 10.8. +// +// One parser for both surfaces, so this table is the answer for the flag and +// for the box on the Preferences screen alike. The refusals name the memory +// setting rather than the size, because the size parser underneath names its +// own and that would send somebody to a flag they did not type. +func TestTheMemorySettingReadsWhatItSays(t *testing.T) { + type want struct { + auto, off bool + bytes int64 + } + for _, c := range []struct { + text string + want want + }{ + {"auto", want{auto: true}}, + {" AUTO ", want{auto: true}}, + {"off", want{off: true}}, + {"Off", want{off: true}}, + {"8gb", want{bytes: 8 << 30}}, + {"1.5gib", want{bytes: 1536 << 20}}, + {"512MB", want{bytes: 512 << 20}}, + {"1", want{bytes: 1}}, + {"1048576", want{bytes: 1 << 20}}, + } { + got, err := core.ParseMemory(c.text) + if err != nil { + t.Errorf("%q was refused: %v", c.text, err) + continue + } + if got.IsAuto() != c.want.auto || got.IsOff() != c.want.off || got.Bytes() != c.want.bytes { + t.Errorf("%q read as auto=%v off=%v bytes=%d, expected auto=%v off=%v bytes=%d", + c.text, got.IsAuto(), got.IsOff(), got.Bytes(), c.want.auto, c.want.off, c.want.bytes) + } + } + + for _, c := range []struct { + text string + // says is a piece of the sentence that has to be there - the reason in + // the person's terms, not the parser's. + says string + }{ + {"", "empty memory"}, + {" ", "empty memory"}, + {"0", "leaves no room at all"}, + {"0gb", "leaves no room at all"}, + {"0.0mb", "leaves no room at all"}, + {"-1", "is negative"}, + {"-8gb", "is negative"}, + {"max", "not a setting this tool knows"}, + {"unlimited", "not a setting this tool knows"}, + {"8 gigs", "unknown unit"}, + {"1e5", "not a plain number"}, + {"1.3", "memory"}, + } { + _, err := core.ParseMemory(c.text) + if err == nil { + t.Errorf("%q was accepted", c.text) + continue + } + var about interface{ AboutSetting() string } + if !errors.As(err, &about) || about.AboutSetting() != core.SettingMemory { + t.Errorf("%q was refused about %q rather than about %q, so a window marks the wrong box: %v", + c.text, settingOf(err), core.SettingMemory, err) + } + msg := err.Error() + if !strings.Contains(msg, c.says) { + t.Errorf("%q was refused with %q, which does not say %q", c.text, msg, c.says) + } + // The size parser's own words for its own setting must not come + // through: "size" names a flag this person did not type, and "a file + // cannot be smaller than zero bytes" is about a file. + if strings.HasPrefix(msg, "size ") || strings.Contains(msg, " size \"") || strings.Contains(msg, "a file cannot") { + t.Errorf("%q was refused in the size parser's words: %q", c.text, msg) + } + } +} + +func settingOf(err error) string { + var about interface{ AboutSetting() string } + if errors.As(err, &about) { + return about.AboutSetting() + } + return "" +} + +// The text a setting is kept as reads back as the same setting, to the byte. +// +// The window keeps the setting in preferences.json as this text and reads it +// at the next start, so a size that came back rounded would be a setting +// changed without anybody touching it. +func TestTheMemorySettingReadsBackAsItself(t *testing.T) { + for _, m := range []core.Memory{ + {}, core.MemoryOff(), + core.MemoryOf(1), core.MemoryOf(1023), core.MemoryOf(1024), core.MemoryOf(1025), + core.MemoryOf(1536 << 20), core.MemoryOf(8 << 30), core.MemoryOf(3 << 40), + core.MemoryOf(math.MaxInt64), + } { + back, err := core.ParseMemory(m.String()) + if err != nil { + t.Errorf("%v (%d B) is kept as %q, which reads back as a refusal: %v", m, m.Bytes(), m.String(), err) + continue + } + if back != m { + t.Errorf("%d B is kept as %q and reads back as %d B", m.Bytes(), m.String(), back.Bytes()) + } + } +} + +// What each setting comes to on a machine of a given size. +// +// The machine is a number here rather than the one the test runs on, so the +// arithmetic is checked on the machines the decisions were made about: half +// for auto, never under the old plan ceiling for the plan, a size used as given +// in both directions, and off as nothing at all. The decisions are the owner's +// of 2026-10-27, docs/STABILITY-REVIEW-2026-10-26.md section 10.10. +func TestTheMemorySettingIsWorkedOutAgainstTheMachine(t *testing.T) { + const gib = int64(1) << 30 + machine := func(n int64, err error) (func() (int64, error), *int) { + calls := new(int) + return func() (int64, error) { *calls++; return n, err }, calls + } + + for _, c := range []struct { + name string + setting core.Memory + machine int64 + fails bool + run, plan int64 + unknown bool + asks bool + }{ + {"auto on 32 GiB", core.Memory{}, 32 * gib, false, 16 * gib, 16 * gib, false, true}, + {"auto on 7 GiB, a CI runner", core.Memory{}, 7 * gib, false, 7 * gib / 2, 7 * gib / 2, false, true}, + {"auto on 2 GiB keeps the old plan ceiling", core.Memory{}, 2 * gib, false, gib, 2 * gib, false, true}, + {"auto on a machine that cannot be asked", core.Memory{}, 0, true, 2 * gib, 2 * gib, true, true}, + {"auto on an answer of nothing", core.Memory{}, 0, false, 2 * gib, 2 * gib, true, true}, + {"auto on an answer of one byte", core.Memory{}, 1, false, 2 * gib, 2 * gib, true, true}, + {"a size above the machine", core.MemoryOf(64 * gib), 8 * gib, false, 64 * gib, 64 * gib, false, false}, + {"a size below the old plan ceiling", core.MemoryOf(100 << 20), 32 * gib, false, 100 << 20, 100 << 20, false, false}, + {"off", core.MemoryOff(), 32 * gib, false, 0, 0, false, false}, + } { + var fail error + if c.fails { + fail = errors.ErrUnsupported + } + ask, calls := machine(c.machine, fail) + got := c.setting.On(ask) + if got.Run != c.run || got.Plan != c.plan || (got.Unknown != nil) != c.unknown { + t.Errorf("%s: run %d plan %d unknown %v, expected run %d plan %d unknown %v", + c.name, got.Run, got.Plan, got.Unknown, c.run, c.plan, c.unknown) + } + if (*calls > 0) != c.asks { + t.Errorf("%s: the machine was asked %d times - a setting that does not need it must not touch the system, and auto must", c.name, *calls) + } + } +} diff --git a/internal/guard/menushape_test.go b/internal/guard/menushape_test.go index 949a0999..cccb5d3f 100644 --- a/internal/guard/menushape_test.go +++ b/internal/guard/menushape_test.go @@ -47,7 +47,7 @@ func TestAMenuIsNotDrawnAsWideAsABoxToTypeIn(t *testing.T) { for _, tab := range allTabs() { screen := selectTab(t, content, tab) menus, boxes := menusOn(screen), typingBoxesOn(screen) - if len(menus) == 0 || holdsNoBoxByDesign(t, tab, boxes) { + if len(menus) == 0 || besideNoBoxByDesign(t, tab, boxes) { continue } widest := float32(0) @@ -175,25 +175,35 @@ func menusOn(screen fyne.CanvasObject) []*parts.Chooser { return found } -// holdsNoBoxByDesign says whether a screen is the one whose menus stand beside +// besideNoBoxByDesign says whether a screen is the one whose menus stand beside // no box to type in, so that a guard comparing a menu with the boxes beside it -// has nothing to compare on it. Preferences holds one menu and no box. +// has nothing to compare on it. Preferences holds one menu, the language, and +// one box - the size of the memory setting, in a section of its own, on the +// screen only while Custom is chosen, and held to the width of a number rather +// than to the column. Neither guard's question is about that pair: the two are +// never in one section, the box is not the column a menu could grow to, and +// the language is not a setting drawn anywhere else at another width. // // Named rather than skipped whenever a screen comes back with no box: a screen // that lost its boxes to a renamed part would otherwise pass these guards by -// not looking. Held both ways - the day a box stands on the named screen, the -// name stops being true out loud. -func holdsNoBoxByDesign(t *testing.T, tab string, boxes []*parts.Entry) bool { +// not looking. Held both ways - the day the count of boxes on the named screen +// changes, the name stops being true out loud. +func besideNoBoxByDesign(t *testing.T, tab string, boxes []*parts.Entry) bool { t.Helper() if tab != text.TabPreferences() { return false } - if len(boxes) != 0 { - t.Errorf("the %s screen holds %d box(es) to type in and is still named as holding none", tab, len(boxes)) + if len(boxes) != preferencesBoxes { + t.Errorf("the %s screen holds %d box(es) to type in and is named as holding %d, the size of the memory setting", + tab, len(boxes), preferencesBoxes) } return true } +// preferencesBoxes is how many boxes to type in the Preferences screen holds: +// the size of the memory setting. +const preferencesBoxes = 1 + func typingBoxesOn(screen fyne.CanvasObject) []*parts.Entry { var found []*parts.Entry walk(screen, func(o fyne.CanvasObject) { @@ -301,7 +311,7 @@ func TestNoMenuIsNarrowerThanTheBoxesItStandsBeside(t *testing.T) { checked := 0 inEveryMenuLayout(t, func(l menuLayout) { boxes := typingBoxesOn(l.screen) - if len(l.menus) == 0 || holdsNoBoxByDesign(t, l.tab, boxes) { + if len(l.menus) == 0 || besideNoBoxByDesign(t, l.tab, boxes) { return } narrowest := float32(0) diff --git a/internal/guard/mutation/entries.py b/internal/guard/mutation/entries.py index 05fdddc7..8ada20f8 100644 --- a/internal/guard/mutation/entries.py +++ b/internal/guard/mutation/entries.py @@ -53,8 +53,8 @@ ( "the preview stops warning that the record will be too big to read back", "internal/gui/window/run.go", - "r.say(append([]string{said}, manifestReachNote(res.Record)...)...)", - "r.say(said)", + "r.say(append(append([]string{said}, manifestReachNote(res.Record)...), runNotesOf(res)...)...)", + "r.say(append([]string{said}, runNotesOf(res)...)...)", "TestTheWindowSaysWhenItsManifestWillBeTooBigToReadBack", ), ( @@ -6170,8 +6170,8 @@ ( "the reason given for a plan refusal names a ceiling other than the one in force", "internal/core/limits.go", - "A(\"Ceiling\", Bytes(MaxPlanBytes)))", - "A(\"Ceiling\", Bytes(MaxPlanBytes/2)))", + "A(\"Ceiling\", Bytes(l.Plan)), A(\"Floor\", Bytes(MaxPlanBytes)))", + "A(\"Ceiling\", Bytes(l.Plan)), A(\"Floor\", Bytes(MaxPlanBytes/2)))", "TestThePlanCeilingIsTheOneCoreStates", ), ( @@ -6218,9 +6218,9 @@ ), ( "the ceiling a caller does not name stops being the real one, so ordinary short runs are refused", - "internal/engine/planmemory.go", - "\t\tceiling = core.MaxPlanBytes", - "\t\tceiling = 1", + "internal/engine/engine.go", + "\treturn o.Memory.On(machine)", + "\treturn core.MemoryOf(1).On(machine)", "TestAShortOrdinaryRunIsNotRefusedByThePlanCeiling", ), ( @@ -8010,8 +8010,8 @@ ( "validate goes back to planning with a context nobody can finish", "internal/cli/recipecmd.go", - "engine.PlanContext(ctx, targets, planningOptions(rec))", - "engine.Plan(targets, planningOptions(rec))", + "engine.PlanContext(ctx, targets, opt)", + "engine.Plan(targets, opt)", "TestPlanningFromTheCommandLineCanBeStopped", ), ( @@ -15918,4 +15918,362 @@ "hash never refuses a write, so this is the stop.\n\t\treturn 0, nil", "TestAnOfficePackageStopsWhileItChecksumsItsPadding", ), + ( + # A nought typed by mistake is read as a size of nothing, which is auto + # - every limit gone without a word. The table asks "0" to be refused. + "the memory setting takes a nought as a setting", + "internal/core/memory.go", + "if n == 0 {", + "if n == -1 {", + "TestTheMemorySettingReadsWhatItSays", + ), + ( + # The size parser's refusal comes through in its own slot, so "8 gigs" + # is refused about the size - a window marks the wrong box and the + # command line names a flag nobody typed. + "a refusal of the size parser keeps naming the size under --memory", + "internal/core/memory.go", + "return &Refusal{said: r.said, setting: SettingMemory}", + "return err", + "TestTheMemorySettingReadsWhatItSays", + ), + ( + # The kept text takes the largest unit the size reaches rather than the + # largest that holds it whole, so 1025 B is kept as 1kb and comes back + # as 1024. + "the memory setting is kept as a rounded size", + "internal/core/memory.go", + "if m.bytes%u.size == 0 {", + "if m.bytes >= u.size {", + "TestTheMemorySettingReadsBackAsItself", + ), + ( + # Auto on a small machine gives the plan half the machine, under the + # ceiling every run had before the setting - a run planned yesterday + # is refused today. + "auto lets the plan ceiling drop below the old two gigabytes", + "internal/core/memory.go", + "return MemoryLimits{Run: half, Plan: max(half, MaxPlanBytes)}", + "return MemoryLimits{Run: half, Plan: half}", + "TestTheMemorySettingIsWorkedOutAgainstTheMachine", + ), + ( + # The walk up the groups keeps the larger limit, so a container held by + # its slice is told the slice's own looser number. + "the larger of two limits on the way up a control group wins", + "internal/core/cgroup.go", + "if ok && (!found || n < least) {", + "if ok && (!found || n > least) {", + "TestTheLimitOfAContainerIsFoundInItsControlGroup", + ), + ( + # The memory free now rather than the memory fitted - a number that + # moves by the minute. Proven on the Linux runner, where mutation runs, + # against MemTotal in /proc/meminfo. A runner whose control group held + # it below the memory fitted would not see this, and the guard logs + # which branch it took. + "the machine is read as the memory free rather than the memory fitted", + "internal/core/machinememory_linux.go", + "fitted := uint64(info.Totalram) * unit", + "fitted := uint64(info.Freeram) * unit", + "TestTheMemoryOfTheMachineIsWhatTheSystemSays", + ), + ( + # Each file takes twice its room, so two that fit side by side are + # written one after the other - the queue holds the writers back + # further than the setting asks. + "the memory queue takes twice the room a file holds", + "internal/engine/parallel.go", + "q.room -= holds", + "q.room -= 2 * holds", + "TestTheFilesWrittenAtOnceHoldNoMoreThanTheMemorySettingLeaves", + ), + ( + # The room is never asked, so every writer holds its picture at once - + # the 2.6 GB of section 3.2 back again. + "the memory queue lets a file in without room for it", + "internal/engine/parallel.go", + "for ctx.Err() == nil && (q.turn != q.rank[i] || (holds > q.room && q.holding > 0)) {", + "for ctx.Err() == nil && (q.turn != q.rank[i] || (false && q.holding > 0)) {", + "TestTheFilesWrittenAtOnceHoldNoMoreThanTheMemorySettingLeaves", + ), + ( + # A file that holds nothing takes a place in the queue, so a text file + # waits behind a picture written on its own. + "a file that holds no memory waits its turn in the memory queue", + "internal/engine/parallel.go", + "if f.Plan.Holds > 0 {\n\t\t\tq.rank[i] = places", + "if f.Plan.Holds >= 0 {\n\t\t\tq.rank[i] = places", + "TestTheFilesWrittenAtOnceHoldNoMoreThanTheMemorySettingLeaves", + ), + ( + # A writer woken after a stop takes its room and starts the file of a + # run that was already stopped. + "a writer woken in the memory queue after a stop starts its file", + "internal/engine/parallel.go", + "\tif ctx.Err() != nil {\n\t\treturn false\n\t}", + "\tif false {\n\t\treturn false\n\t}", + "TestTheFilesWrittenAtOnceHoldNoMoreThanTheMemorySettingLeaves", + ), + ( + # A file written past the setting is written and nothing says so - + # untouchable rule 6. + "a file written past the memory setting goes unsaid", + "internal/engine/parallel.go", + "if holds > q.room && q.over == nil {", + "if false && q.over == nil {", + "TestTheFilesWrittenAtOnceHoldNoMoreThanTheMemorySettingLeaves", + ), + ( + # Off still weighs the plan, against a ceiling of nothing - every plan + # refused under the setting that asked for no limit. + "off weighs the plan against a ceiling nobody asked for", + "internal/engine/planmemory.go", + "\tif limits.Plan <= 0 {\n\t\treturn nil\n\t}", + "\tif false {\n\t\treturn nil\n\t}", + "TestOffWeighsNothing", + ), + ( + # Planning works to the old two gigabytes whatever the setting says - + # the ceiling that could not be moved, which is R02. + "planning ignores the memory setting and works to the old ceiling", + "internal/engine/engine.go", + "pl.budget = newPlanMemory(opt.Memory, opt.memoryLimits())", + "pl.budget = newPlanMemory(opt.Memory, core.MemoryLimits{Plan: core.MaxPlanBytes})", + "TestThePlanCeilingIsTheMemorySetting", + ), + ( + # A jpg declares nothing, so sixteen of its pictures go out at once + # whatever the memory setting says - R02 back for that one format. + "a jpg does not declare the picture writing it holds", + "internal/format/jpg/jpg.go", + "\tp.Holds = holdsPerPixel * int64(w) * int64(h)\n", + "\tp.Holds = 0 * holdsPerPixel * int64(w) * int64(h)\n", + "TestEveryPictureSaysWhatWritingItHolds", + ), + ( + "a png does not declare the picture writing it holds", + "internal/format/png/png.go", + "\tp.Holds = holdsPerPixel * int64(w) * int64(h)\n", + "\tp.Holds = 0 * holdsPerPixel * int64(w) * int64(h)\n", + "TestEveryPictureSaysWhatWritingItHolds", + ), + ( + "a gif does not declare the picture writing it holds", + "internal/format/gif/gif.go", + "\tp.Holds = holdsPerPixel * int64(w) * int64(h) / 2\n", + "\tp.Holds = 0 * holdsPerPixel * int64(w) * int64(h) / 2\n", + "TestEveryPictureSaysWhatWritingItHolds", + ), + ( + # The film declares the part that does not grow and drops the part + # that does, so a 4K film is held back like a small one. + "a film declares the same whatever the size of its picture", + "internal/format/video/plan.go", + "\treturn 4<<20 + 2*int64(width)*int64(height)", + "\treturn 4<<20 + 0*int64(width)*int64(height)", + "TestEveryPictureSaysWhatWritingItHolds", + ), + ( + # A jxl chosen from the ladder is coded while writing and declares + # nothing, so the setting cannot see it. + "a jxl coded while writing declares nothing", + "internal/format/jxl/jxl.go", + "\tif m.coded == nil {\n\t\t// Coded while writing", + "\tif false && m.coded == nil {\n\t\t// Coded while writing", + "TestEveryPictureItChoosesSaysWhatWritingItHolds", + ), + ( + "an avif coded while writing declares nothing", + "internal/format/avif/avif.go", + "\tif m.coded == nil {\n\t\t// Coded while writing", + "\tif false && m.coded == nil {\n\t\t// Coded while writing", + "TestEveryPictureItChoosesSaysWhatWritingItHolds", + ), + ( + # A zip of pictures declares nothing, though it codes each picture + # while it writes. + "a zip does not pass up what its pictures hold", + "internal/format/zip/zip.go", + "\tp.Holds = format.OneAtATime(m.children, func(c child) format.Plan { return c.plan })\n", + "\tp.Holds = 0 * format.OneAtATime(m.children, func(c child) format.Plan { return c.plan })\n", + "TestAContainerHoldsWhatItsLargestPictureHolds", + ), + ( + # The pictures a message shows in its text are a list of their own, + # and leaving them out leaves a message of pictures declaring nothing. + "a mail message does not pass up what the pictures in its text hold", + "internal/format/eml/eml.go", + "format.OneAtATime(m.pictures, plan))", + "0*format.OneAtATime(m.pictures, plan))", + "TestAContainerHoldsWhatItsLargestPictureHolds", + ), + ( + # A container adds its pictures up rather than taking the largest, as + # if it wrote them all at once - every container of pictures waits + # for room it will never use. + "a container declares the sum of its pictures rather than the largest", + "internal/format/container.go", + "\t\tmost = max(most, plan(f).Holds)", + "\t\tmost += plan(f).Holds", + "TestAContainerHoldsWhatItsLargestPictureHolds", + ), + ( + # generate reads --memory and then hands the planner auto instead - + # the flag is accepted and does nothing, which is the worst kind of + # option this tool can have. + "generate reads --memory and plans under auto anyway", + "internal/cli/generate.go", + "\t\tMemory: memory.Memory,", + "\t\tMemory: memoryValue{}.Memory,", + "TestTheMemoryFlagReachesThePlanOnEveryCommandThatPlans", + ), + ( + "validate reads --memory and plans under auto anyway", + "internal/cli/recipecmd.go", + "\topt.Memory = v.memory.Memory", + "\topt.Memory = memoryValue{}.Memory", + "TestTheMemoryFlagReachesThePlanOnEveryCommandThatPlans", + ), + ( + "preset show reads --memory and counts the budget under auto anyway", + "internal/cli/presetcmd.go", + "budgetOf(ctx, expanded, memory.Memory)", + "budgetOf(ctx, expanded, memoryValue{}.Memory)", + "TestTheMemoryFlagReachesThePlanOnEveryCommandThatPlans", + ), + ( + # The help of preset show is written by hand, so the flag can be there + # and its sentence missing. + "the help of preset show does not say what --memory does", + "internal/cli/presetcmd.go", + " \t`+memoryHelp+` (default auto)", + " \t(default auto)", + "TestTheMemoryFlagReachesThePlanOnEveryCommandThatPlans", + ), + ( + # The command line prints the manifest's notes and not the run's, so a + # file written past the memory setting goes unsaid there - rule 6. + "the command line does not print what the run says about its memory", + "internal/cli/generate.go", + "sayNotes(append(saidAll(res.RunNotes), res.Manifest.Notes()...), errOut)", + "sayNotes(res.Manifest.Notes(), errOut)", + "TestAFileWrittenPastTheMemorySettingIsSaidOnTheCommandLine", + ), + ( + # A preview from the window plans under auto whatever Preferences + # keeps - the setting is on the screen and does nothing. + "a preview in the window ignores the memory setting kept on Preferences", + "internal/gui/window/run.go", + "\topt.DryRun = true\n\topt.Memory, opt.MachineMemory = memoryOf(r.kept()), r.machine", + "\topt.DryRun = true\n\topt.Memory, opt.MachineMemory = core.Memory{}, r.machine", + "TestARunPlansUnderTheMemorySettingKeptOnPreferences", + ), + ( + "a run in the window ignores the memory setting kept on Preferences", + "internal/gui/window/run.go", + "\topt.Memory, opt.MachineMemory = memoryOf(r.kept()), r.machine\n\tctx, cancel", + "\topt.Memory, opt.MachineMemory = core.Memory{}, r.machine\n\tctx, cancel", + "TestARunPlansUnderTheMemorySettingKeptOnPreferences", + ), + ( + # Automatic is kept as the word auto rather than as no value, so + # Forget and the language's rule of "nothing chosen is nothing kept" + # part ways. + "Automatic is written into preferences.json rather than left out", + "internal/gui/window/prefmemory.go", + "\tif m.IsAuto() {\n\t\treturn \"\"", + "\tif false {\n\t\treturn \"\"", + "TestTheMemorySettingIsChosenOnPreferencesAndKept", + ), + ( + # A kept value that does not read is shown as Automatic without a word + # about what was there. + "an unreadable memory setting is shown as Automatic without saying so", + "internal/gui/window/prefmemory.go", + "\t\tunreadable = kept\n", + "\t\t_ = kept\n", + "TestOpeningPreferencesKeepsAnUnreadableMemorySetting", + ), + ( + "a box for the memory that holds no size is not marked", + "internal/gui/window/prefmemory.go", + "\t\tm.fields.Mark(core.SettingMemory, err)\n", + "\t\t_ = err\n", + "TestTheMemorySettingIsChosenOnPreferencesAndKept", + ), + ( + # Review on #208. Version 1 reads its own group alone again, and an + # unlimited session under a limited slice is planned as the machine. + "version 1 reads the limit of its own group and not the slice above it", + "internal/core/cgroup.go", + "return limitAbove(read, mount, rel, \"memory.limit_in_bytes\")", + "return limitIn(read, path.Join(mount, rel, \"memory.limit_in_bytes\"))", + "TestTheLimitOfAContainerIsFoundInItsControlGroup", + ), + ( + # Review on #208. A group with ".." in it is joined to the mount and + # read wherever it lands. + "a group with a step out of its mount is followed rather than refused", + "internal/core/cgroup.go", + "\t\tcase rel != \"\" && path.Clean(rel) != rel:", + "\t\tcase false:", + "TestTheLimitOfAContainerIsFoundInItsControlGroup", + ), + ( + # Review on #208. Forget leaves the size in the box, and Custom chosen + # after it keeps the size again. + "Forget leaves the forgotten size in the box", + "internal/gui/window/prefmemory.go", + "\tm.size.SetText(\"\")\n\tm.fields.Clear(core.SettingMemory)\n\tparsed", + "\tm.fields.Clear(core.SettingMemory)\n\tparsed", + "TestTheMemorySettingIsChosenOnPreferencesAndKept", + ), + ( + # Review on #208. What a run said about its memory stays in the result + # and never reaches the window. + "a run in the window does not say what it said about its memory", + "internal/gui/window/run.go", + "\tsaid = append(said, runNotesOf(res)...)\n", + "\tsaid = append(said, notesOf(nil)...)\n", + "TestTheWindowSaysWhatARunSaidAboutItsMemory", + ), + ( + "a preview in the window does not say what it said about its memory", + "internal/gui/window/run.go", + "manifestReachNote(res.Record)...), runNotesOf(res)...)...)", + "manifestReachNote(res.Record)...), notesOf(nil)...)...)", + "TestTheWindowSaysWhatARunSaidAboutItsMemory", + ), + ( + # The run asks the system rather than the host the Preferences screen + # asks, so the screen and the run can name two different machines. + "a run in the window asks the system for the machine rather than the host", + "internal/gui/window/run.go", + "\topt.Memory, opt.MachineMemory = memoryOf(r.kept()), r.machine\n\tctx, cancel", + "\topt.Memory, opt.MachineMemory = memoryOf(r.kept()), nil\n\tctx, cancel", + "TestTheWindowSaysWhatARunSaidAboutItsMemory", + ), + ( + "a preview in the window asks the system for the machine rather than the host", + "internal/gui/window/run.go", + "\topt.DryRun = true\n\topt.Memory, opt.MachineMemory = memoryOf(r.kept()), r.machine", + "\topt.DryRun = true\n\topt.Memory, opt.MachineMemory = memoryOf(r.kept()), nil", + "TestTheWindowSaysWhatARunSaidAboutItsMemory", + ), + ( + # Review on #208. The help names a floor other than the one auto keeps. + "--help names a floor of the plan other than the one auto keeps", + "internal/cli/memoryflag.go", + "core.HumanBytes(core.MaxPlanBytes) + \" is never refused\"", + "core.HumanBytes(core.MaxPlanBytes*2) + \" is never refused\"", + "TestTheMemoryFlagReachesThePlanOnEveryCommandThatPlans", + ), + ( + "the Preferences screen names a floor of the plan other than the one auto keeps", + "internal/gui/text/preferences.go", + "map[string]any{\"Floor\": HumanBytes(core.MaxPlanBytes)}", + "map[string]any{\"Floor\": HumanBytes(core.MaxPlanBytes * 2)}", + "TestTheMemorySettingIsChosenOnPreferencesAndKept", + ), ] diff --git a/internal/guard/planmemory_test.go b/internal/guard/planmemory_test.go index bc8ce87e..2571b187 100644 --- a/internal/guard/planmemory_test.go +++ b/internal/guard/planmemory_test.go @@ -1,6 +1,8 @@ package guard import ( + "errors" + "runtime" "runtime/debug" "strings" "testing" @@ -49,7 +51,7 @@ func TestAPlanTooBigToHoldIsRefusedWhileItIsBuilt(t *testing.T) { _, err := engine.Plan(targets, engine.Options{ OutDir: "out", ManifestName: "manifest.json", - MaxPlanBytes: 4 << 20, + Memory: core.MemoryOf(4 << 20), }) if err == nil { t.Fatal("planning fifty thousand files under a 4 MB plan ceiling was accepted") @@ -89,19 +91,40 @@ func TestAPlanTooBigToHoldIsRefusedWhileItIsBuilt(t *testing.T) { // never read again. The full mutation run of 2026-09-16 found exactly that - // a 2 KB ceiling green under four thousand files. At ten thousand the second // reading lands on four thousand planned files that nothing can collect. +// +// On a machine whose half cannot hold the run, so that the floor of auto is +// the ceiling in force. On any machine of more than four gigabytes half is +// above the floor, and a floor dropped to nothing went unseen there - the +// mutation run on #208 found exactly that. Asserted rather than assumed: half +// of the same machine, set as a size, refuses the run. func TestAnOrdinaryRunIsNotRefusedByThePlanCeiling(t *testing.T) { const files = 10000 + const machine = 4 << 20 targets := []engine.Target{{ ID: "files", Format: "txt", Sizes: engine.Uniform(files, 4096), }} + opt := engine.Options{ + OutDir: "out", + ManifestName: "manifest.json", + MachineMemory: func() (int64, error) { return machine, nil }, + } - planned, err := engine.Plan(targets, engine.Options{ - OutDir: "out", - ManifestName: "manifest.json", - // Zero means the real ceiling, which is what every caller passes. - }) + half, err := core.ParseMemorySize(core.HumanBytes(machine / 2)) + if err != nil { + t.Fatal(err) + } + withHalf := opt + withHalf.Memory = half + if _, err := engine.Plan(targets, withHalf); err == nil { + t.Fatalf("ten thousand text files planned under %s, half of this machine, so the floor"+ + " is not what holds the run below and this guard asks nothing of it", half) + } + + // The zero value of Memory is auto, which is what a caller that sets + // nothing gets - two gigabytes of ceiling at the least. + planned, err := engine.Plan(targets, opt) if err != nil { t.Fatalf("ten thousand text files were refused under the real ceiling: %v", err) } @@ -144,7 +167,7 @@ func TestARunTooShortToHaveBeenWeighedIsWeighed(t *testing.T) { _, err := engine.Plan(targets, engine.Options{ OutDir: "out", ManifestName: "manifest.json", - MaxPlanBytes: 4 << 20, + Memory: core.MemoryOf(4 << 20), }) if err == nil { t.Fatal("one file whose plan is six times the ceiling was accepted, so nothing weighed it") @@ -179,7 +202,8 @@ func TestAShortOrdinaryRunIsNotRefusedByThePlanCeiling(t *testing.T) { files, err := engine.Plan(targets, engine.Options{ OutDir: "out", ManifestName: "manifest.json", - // Zero means the real ceiling, which is what every caller passes. + // The zero value of Memory is auto, which is what a caller that sets + // nothing gets - two gigabytes of ceiling at the least. }) if err != nil { t.Fatalf("a single thousand page pdf was refused under the real ceiling: %v", err) @@ -189,11 +213,14 @@ func TestAShortOrdinaryRunIsNotRefusedByThePlanCeiling(t *testing.T) { } } -// The ceiling this build works to is the one core states. +// The floor under auto is the one core states, and every refusal names its +// ceiling and where it came from. // // Asked separately from the mechanism above, because that one runs against an // injected number and would stay green if the real constant were changed to -// something a run could never reach. +// something a run could never reach. Since 2026-10-27 the constant is the +// floor auto never goes under rather than the ceiling for every run +// (docs/STABILITY-REVIEW-2026-10-26.md section 10.10). func TestThePlanCeilingIsTheOneCoreStates(t *testing.T) { if core.MaxPlanBytes <= 0 { t.Fatalf("the plan ceiling is %d, which no run can be under", core.MaxPlanBytes) @@ -205,11 +232,93 @@ func TestThePlanCeilingIsTheOneCoreStates(t *testing.T) { t.Errorf("the plan ceiling is %d B, which is under the 256 MB the largest shapes this tool ships need", core.MaxPlanBytes) } - // And the sentence has to name it, or the refusal sends somebody looking - // for a number that is written somewhere else. - if !strings.Contains(core.PlanTooLargeWhy.String(), core.HumanBytes(core.MaxPlanBytes)) { - t.Errorf("the refusal reason does not name the ceiling %s: %s", - core.HumanBytes(core.MaxPlanBytes), core.PlanTooLargeWhy) + // And each sentence has to name its number and its source, or the refusal + // sends somebody looking for a number that is written somewhere else - or + // for a setting they never touched. + floor := core.HumanBytes(core.MaxPlanBytes) + for _, c := range []struct { + name string + setting core.Memory + limits core.MemoryLimits + says []string + }{ + {"a size", core.MemoryOf(100 << 20), core.MemoryLimits{Run: 100 << 20, Plan: 100 << 20}, + []string{core.HumanBytes(100 << 20), "the memory setting"}}, + {"auto", core.Memory{}, core.MemoryLimits{Run: 8 << 30, Plan: 8 << 30}, + []string{core.HumanBytes(8 << 30), "half of what this machine gives", floor}}, + {"auto on a machine that cannot be asked", core.Memory{}, core.MemoryLimits{Run: core.MaxPlanBytes, Plan: core.MaxPlanBytes, Unknown: errors.ErrUnsupported}, + []string{floor, "could not be read"}}, + } { + why := core.PlanTooLargeWhy(c.setting, c.limits).String() + for _, want := range c.says { + if !strings.Contains(why, want) { + t.Errorf("%s: the refusal reason does not say %q: %s", c.name, want, why) + } + } + } + if fix := core.PlanTooLargeFix.String(); !strings.Contains(fix, "memory setting") { + t.Errorf("the remedy does not name the memory setting, which is what moves the ceiling: %s", fix) + } +} + +// The plan's ceiling is the memory setting's, through the engine. +// +// The arithmetic is checked against numbers in +// TestTheMemorySettingIsWorkedOutAgainstTheMachine. This is the other half: +// that planning asks the setting at all, and asks it about the machine it was +// given. The shape is the one TestAPlanTooBigToHoldIsRefusedWhileItIsBuilt +// uses, about 42 MB of plan, so a ceiling of four megabytes refuses it by ten +// times and the floor of two gigabytes takes it fifty times over. +func TestThePlanCeilingIsTheMemorySetting(t *testing.T) { + targets := []engine.Target{{ID: "files", Format: "txt", Sizes: engine.Uniform(50000, 4096)}} + tiny := func() (int64, error) { return 8 << 20, nil } + + _, err := engine.Plan(targets, engine.Options{OutDir: "out", ManifestName: "manifest.json", + Memory: core.MemoryOf(4 << 20), MachineMemory: tiny}) + if err == nil { + t.Fatal("a plan ten times the size the memory setting gives it was accepted") + } + if !strings.Contains(err.Error(), "the memory setting of this run gives it "+core.HumanBytes(4<<20)) { + t.Errorf("the refusal does not say the ceiling was the memory setting's: %s", err) + } + + // Auto on a machine of eight megabytes is four - and the plan's ceiling + // under auto never goes below the one every run had before the setting, + // so the same plan is taken. + if _, err := engine.Plan(targets, engine.Options{OutDir: "out", ManifestName: "manifest.json", + MachineMemory: tiny}); err != nil { + t.Errorf("auto on a small machine refused a plan under the old ceiling of two gigabytes: %v", err) + } +} + +// Off weighs nothing. +// +// No ceiling is the setting's whole meaning, and the only way to show the +// absence of one cheaply is to show that nothing was measured: weighing a plan +// starts with a collection, which is what takes its reference point. With the +// collector switched off, only a collection somebody asked for happens - so a +// run under auto has at least one and a run under off has none. The auto half +// is asked as well, or a count that never moves would pass the off half for +// any build. +func TestOffWeighsNothing(t *testing.T) { + was := debug.SetGCPercent(-1) + t.Cleanup(func() { debug.SetGCPercent(was) }) + targets := []engine.Target{{ID: "one", Format: "txt", Sizes: engine.Uniform(1, 64)}} + + collections := func(m core.Memory) uint32 { + var before, after runtime.MemStats + runtime.ReadMemStats(&before) + if _, err := engine.Plan(targets, engine.Options{OutDir: "out", ManifestName: "manifest.json", Memory: m}); err != nil { + t.Fatal(err) + } + runtime.ReadMemStats(&after) + return after.NumForcedGC - before.NumForcedGC + } + if n := collections(core.Memory{}); n == 0 { + t.Fatal("planning under auto asked for no collection, so this cannot tell off from auto") + } + if n := collections(core.MemoryOff()); n != 0 { + t.Errorf("planning under off asked for %d collection(s) - the plan was weighed against a ceiling nobody asked for", n) } } @@ -257,7 +366,7 @@ func TestThePlanCeilingAsksAboutAllocationRatherThanTheLastCollection(t *testing _, err := engine.Plan(targets, engine.Options{ OutDir: "out", ManifestName: "manifest.json", - MaxPlanBytes: 4 << 20, + Memory: core.MemoryOf(4 << 20), }) if err == nil { t.Fatal("with the collector switched off a plan six times the ceiling was accepted, " + diff --git a/internal/guard/prefmemory_test.go b/internal/guard/prefmemory_test.go new file mode 100644 index 00000000..8a20d1b4 --- /dev/null +++ b/internal/guard/prefmemory_test.go @@ -0,0 +1,211 @@ +package guard + +import ( + "errors" + "strings" + "testing" + + "fyne.io/fyne/v2" + + "github.com/donislawdev/TestingFilesGenerator/internal/core" + "github.com/donislawdev/TestingFilesGenerator/internal/gui/parts" + "github.com/donislawdev/TestingFilesGenerator/internal/gui/text" + "github.com/donislawdev/TestingFilesGenerator/internal/gui/window" +) + +// The Memory section of the Preferences screen - the window's --memory, +// docs/STABILITY-REVIEW-2026-10-26.md section 10.8. +// +// Chosen on a switch, kept at once the way the language is, and read by the +// next run. The machine is the stand in host's sixteen gigabytes, so Automatic +// reads eight on every runner. +func TestTheMemorySettingIsChosenOnPreferencesAndKept(t *testing.T) { + host := newFakeHost(t) + window.Open(host) + prefs := selectTab(t, host.content, text.TabPreferences()) + way, size := memoryControls(t, prefs) + + // Nothing kept: Automatic, and what it comes to on this machine. + if way.Selected != text.MemoryWayAuto() || host.Remembered().Memory() != "" { + t.Fatalf("with nothing kept the switch shows %q and %q is kept, not Automatic and nothing", way.Selected, host.Remembered().Memory()) + } + if said := textIn(prefs); !strings.Contains(said, text.MemoryAutoIs(text.HumanBytes(8<<30))) { + t.Errorf("the screen does not say what Automatic comes to on a machine of sixteen gigabytes:\n%s", said) + } + // And the floor the plan keeps under it, from the constant that sets it - + // review on #208: without it a machine of four gigabytes or less reads a + // refusal the run would never give. + if said := textIn(prefs); !strings.Contains(said, "a plan of up to "+text.HumanBytes(core.MaxPlanBytes)+" is never refused") { + t.Errorf("the screen does not say the floor of the plan under Automatic:\n%s", said) + } + + // Custom keeps the size as it is typed, and a size that is not one is + // marked and kept as Automatic rather than as what was typed. + way.SetSelected(text.MemoryWayCustom()) + size.SetText("512mb") + if got := host.Remembered().Memory(); got != "512mb" { + t.Errorf("512mb was typed under Custom and %q was kept", got) + } + size.SetText("lots") + if got := host.Remembered().Memory(); got != "" { + t.Errorf("%q is not a size and %q was kept for it", "lots", got) + } + // The refusal is asked for by its words, not by the value: "lots" is on + // the screen anyway, in the box it was typed into. + if said := textIn(prefs); !strings.Contains(said, text.MemoryCustomEmpty()) || !strings.Contains(said, "is not a size") { + t.Errorf("a box holding something that is not a size is not marked, or the line does not say runs use Automatic:\n%s", said) + } + size.SetText("off") + if got := host.Remembered().Memory(); got != "" { + t.Errorf("off typed into the box for a size was kept as %q - No limit is chosen on the switch, not typed", got) + } + + way.SetSelected(text.MemoryWayOff()) + if got := host.Remembered().Memory(); got != "off" { + t.Errorf("No limit was chosen and %q was kept", got) + } + way.SetSelected(text.MemoryWayAuto()) + if got := host.Remembered().Memory(); got != "" { + t.Errorf("Automatic was chosen and %q stayed kept", got) + } + + // Forget takes it away with the rest, and the switch goes back with it. + // Kept as a size, so the box holds one that reads - and the size goes with + // the rest: a box left holding it would keep it again the moment Custom is + // chosen after Forget. + way.SetSelected(text.MemoryWayCustom()) + size.SetText("512mb") + if got := host.Remembered().Memory(); got != "512mb" { + t.Fatalf("512mb was typed under Custom and %q was kept", got) + } + buttonNamed(prefs, text.ButtonForget()).OnTapped() + if got := host.Remembered().Memory(); got != "" || way.Selected != text.MemoryWayAuto() { + t.Errorf("after Forget %q is kept and the switch shows %q", got, way.Selected) + } + way.SetSelected(text.MemoryWayCustom()) + if got := host.Remembered().Memory(); got != "" || size.Text != "" { + t.Errorf("Custom chosen after Forget keeps %q with %q in the box - the size Forget took away came back", got, size.Text) + } +} + +// Opening the screen writes nothing, and a kept value that does not read is +// shown as Automatic and named rather than written over - untouchable rule 7, +// as for a language this build does not carry. +func TestOpeningPreferencesKeepsAnUnreadableMemorySetting(t *testing.T) { + host := newFakeHost(t) + host.Remembered().RememberMemory("lots") + window.Open(host) + prefs := selectTab(t, host.content, text.TabPreferences()) + way, _ := memoryControls(t, prefs) + if got := host.Remembered().Memory(); got != "lots" { + t.Errorf("a kept value of lots became %q by opening the window", got) + } + if way.Selected != text.MemoryWayAuto() { + t.Errorf("an unreadable kept value shows as %q, and a run uses Automatic for it", way.Selected) + } + if said := textIn(prefs); !strings.Contains(said, text.MemoryUnreadable("lots")) { + t.Errorf("the screen does not say the kept value could not be read:\n%s", said) + } + + // And a size kept before opens on Custom with the size in the box. + host = newFakeHost(t) + host.Remembered().RememberMemory("3gb") + window.Open(host) + prefs = selectTab(t, host.content, text.TabPreferences()) + way, size := memoryControls(t, prefs) + if way.Selected != text.MemoryWayCustom() || size.Text != "3gb" || host.Remembered().Memory() != "3gb" { + t.Errorf("3gb kept opens as %q with %q in the box and %q kept", way.Selected, size.Text, host.Remembered().Memory()) + } +} + +// The setting kept on Preferences is the one the next run plans under. +// +// A ceiling of one megabyte against twenty thousand text files, about 17 MB of +// plan, from the Generate screen's preview: the refusal has to be the memory +// setting's, which only a runner that read the kept value can give. +func TestARunPlansUnderTheMemorySettingKeptOnPreferences(t *testing.T) { + host := newFakeHost(t) + host.Remembered().RememberMemory("1mb") + window.Open(host) + t.Cleanup(func() { join(host) }) + content := tabNamed(t, host.content, text.TabOneTarget()) + fill(t, content, text.FieldOutputDir(), t.TempDir()) + fill(t, content, text.FieldSize(), "4kb") + fill(t, content, text.FieldCount(), "20000") + ceiling := "the memory setting of this run gives it " + text.HumanBytes(1<<20) + press(t, content, text.ButtonPreview()) + join(host) + if said := textIn(content); !strings.Contains(said, ceiling) { + t.Errorf("a preview of 17 MB of plan under a kept setting of 1 MB was not refused by the setting:\n%s", said) + } + // The run reads it as well as the preview - they start apart, and each + // reads the setting for itself. + press(t, content, text.ButtonGenerate()) + join(host) + if said := textIn(content); !strings.Contains(said, ceiling) { + t.Errorf("a run of 17 MB of plan under a kept setting of 1 MB was not refused by the setting:\n%s", said) + } +} + +// What a run says about the memory it ran in reaches the window, from a +// preview and from a run - review on #208. The command line has said it since +// the setting existed, and the window said only what the run said about its +// files, so a run that could not ask the machine worked to a limit nobody on +// the screen was told the source of (untouchable rule 6). +// +// Asked on a host that cannot say how much memory the machine has, which is +// also the proof that a run asks the same host the Preferences screen asks. +func TestTheWindowSaysWhatARunSaidAboutItsMemory(t *testing.T) { + host := newFakeHost(t) + host.machineFails = true + window.Open(host) + t.Cleanup(func() { join(host) }) + content := tabNamed(t, host.content, text.TabOneTarget()) + fill(t, content, text.FieldOutputDir(), t.TempDir()) + fill(t, content, text.FieldSize(), "1kb") + fill(t, content, text.FieldCount(), "2") + const said = "the memory of this machine could not be read" + for _, button := range []string{text.ButtonPreview(), text.ButtonGenerate()} { + press(t, content, button) + join(host) + if on := textIn(content); !strings.Contains(strings.ToLower(on), said) { + t.Errorf("%s on a machine that could not be asked does not say so:\n%s", button, on) + } + } +} + +// memoryControls are the switch and the box of the Memory section. The screen +// holds no other switch and no other box, which is asserted rather than +// assumed, or the guard could be pressing a control of another section. +func memoryControls(t *testing.T, o fyne.CanvasObject) (*parts.Segments, *parts.Entry) { + t.Helper() + var ways []*parts.Segments + var boxes []*parts.Entry + walk(o, func(obj fyne.CanvasObject) { + switch c := obj.(type) { + case *parts.Segments: + ways = append(ways, c) + case *parts.Entry: + boxes = append(boxes, c) + } + }) + if len(ways) != 1 || len(boxes) != 1 { + t.Fatalf("the Preferences screen holds %d switches and %d boxes, expected the one of each the Memory section has", len(ways), len(boxes)) + } + return ways[0], boxes[0] +} + +// MachineMemory is a machine of sixteen gigabytes unless a guard said +// otherwise - never the memory of the runner the guard is on, or the stored +// picture of the Preferences screen would differ from one runner to the next. +func (h *fakeHost) MachineMemory() (int64, error) { + switch { + case h.machineFails: + return 0, errors.ErrUnsupported + case h.machine > 0: + return h.machine, nil + } + return fakeMachineMemory, nil +} + +const fakeMachineMemory = 16 << 30 diff --git a/internal/guard/testdata/screens/preferences-chosen.png b/internal/guard/testdata/screens/preferences-chosen.png index 1da4ac0a..16dc4cc1 100644 Binary files a/internal/guard/testdata/screens/preferences-chosen.png and b/internal/guard/testdata/screens/preferences-chosen.png differ diff --git a/internal/guard/testdata/screens/preferences-chosen.xml b/internal/guard/testdata/screens/preferences-chosen.xml index 6e9a0f5e..4e72e83d 100644 --- a/internal/guard/testdata/screens/preferences-chosen.xml +++ b/internal/guard/testdata/screens/preferences-chosen.xml @@ -42,8 +42,8 @@ - - + + @@ -51,16 +51,17 @@ - - - - How this window speaks, and what it keeps between runs. Nothing here changes the files it makes. + + + + How this window speaks, how much memory its runs may take, and what it keeps between runs. Nothing here + changes the files it makes. - + @@ -112,7 +113,53 @@ - + + + + + Memory + + + + + + Memory for a run + + + + + + + + Automatic + Custom + No limit + + + + + + + + + How much memory one run may take, the plan and the files being written together. Pictures wait for room rather than + all being held at once, and a plan larger than this is refused before anything is written. Under Automatic, a plan of up to + 2.0 GB is never refused. It applies from the next run, and nothing in the files changes. + + + + + + + Runs may take 8.0 GB, half of the memory this computer gives the program. + + + + + + + + @@ -121,8 +168,8 @@ - Between runs this window keeps three things: the output directory, the size of the window, and the language chosen - above. The folder picker also keeps the last folder it showed. Nothing leaves this computer. + Between runs this window keeps four things: the output directory, the size of the window, and the language and the + memory chosen above. The folder picker also keeps the last folder it showed. Nothing leaves this computer. @@ -157,7 +204,7 @@ - Forget clears the three things above. This window then keeps nothing when it closes, and the next one opens the way + Forget clears the four things above. This window then keeps nothing when it closes, and the next one opens the way the first one did. diff --git a/internal/guard/testdata/screens/preferences.png b/internal/guard/testdata/screens/preferences.png index 2cb78a3c..42f893dc 100644 Binary files a/internal/guard/testdata/screens/preferences.png and b/internal/guard/testdata/screens/preferences.png differ diff --git a/internal/guard/testdata/screens/preferences.xml b/internal/guard/testdata/screens/preferences.xml index a4df9a9d..b44c3a0b 100644 --- a/internal/guard/testdata/screens/preferences.xml +++ b/internal/guard/testdata/screens/preferences.xml @@ -42,8 +42,8 @@ - - + + @@ -51,16 +51,17 @@ - - - - How this window speaks, and what it keeps between runs. Nothing here changes the files it makes. + + + + How this window speaks, how much memory its runs may take, and what it keeps between runs. Nothing here + changes the files it makes. - + @@ -112,7 +113,53 @@ - + + + + + Memory + + + + + + Memory for a run + + + + + + + + Automatic + Custom + No limit + + + + + + + + + How much memory one run may take, the plan and the files being written together. Pictures wait for room rather than + all being held at once, and a plan larger than this is refused before anything is written. Under Automatic, a plan of up to + 2.0 GB is never refused. It applies from the next run, and nothing in the files changes. + + + + + + + Runs may take 8.0 GB, half of the memory this computer gives the program. + + + + + + + + @@ -121,8 +168,8 @@ - Between runs this window keeps three things: the output directory, the size of the window, and the language chosen - above. The folder picker also keeps the last folder it showed. Nothing leaves this computer. + Between runs this window keeps four things: the output directory, the size of the window, and the language and the + memory chosen above. The folder picker also keeps the last folder it showed. Nothing leaves this computer. @@ -157,7 +204,7 @@ - Forget clears the three things above. This window then keeps nothing when it closes, and the next one opens the way + Forget clears the four things above. This window then keeps nothing when it closes, and the next one opens the way the first one did. diff --git a/internal/guard/window_test.go b/internal/guard/window_test.go index ce2a69a3..dbb0b831 100644 --- a/internal/guard/window_test.go +++ b/internal/guard/window_test.go @@ -61,6 +61,12 @@ type fakeHost struct { // Restart now asked for a new window. system string restarts int + // machine is what MachineMemory answers when a guard set it, and + // machineFails makes it answer that it could not ask. Nothing set is the + // fixed figure below, so a stored picture of the Preferences screen is the + // same on every runner. + machine int64 + machineFails bool // waitForWork waits for a preview or a run to finish, without stopping it. // // Separate from intercept, which is the CLOSE intercept and now cancels. @@ -427,6 +433,7 @@ type keptInMemory struct { size fyne.Size dirWrite int language string + memory string forgets int } @@ -439,11 +446,13 @@ func (k *keptInMemory) Size() fyne.Size { return k.size } func (k *keptInMemory) RememberSize(s fyne.Size) { k.size = s } func (k *keptInMemory) Language() string { return k.language } func (k *keptInMemory) RememberLanguage(tag string) { k.language = tag } +func (k *keptInMemory) Memory() string { return k.memory } +func (k *keptInMemory) RememberMemory(m string) { k.memory = m } // Forget removes the values, as the real store does. What it promises for // the rest of the run is window.Forgetting's, which Remembered wraps this in. func (k *keptInMemory) Forget() { - k.dir, k.size, k.language = "", fyne.Size{}, "" + k.dir, k.size, k.language, k.memory = "", fyne.Size{}, "", "" k.forgets++ } diff --git a/internal/gui/run_cgo.go b/internal/gui/run_cgo.go index 37bc91ef..e2666c39 100644 --- a/internal/gui/run_cgo.go +++ b/internal/gui/run_cgo.go @@ -16,6 +16,7 @@ import ( "fyne.io/fyne/v2/dialog" "fyne.io/fyne/v2/lang" + "github.com/donislawdev/TestingFilesGenerator/internal/core" "github.com/donislawdev/TestingFilesGenerator/internal/gui/catalogue" "github.com/donislawdev/TestingFilesGenerator/internal/gui/icon" "github.com/donislawdev/TestingFilesGenerator/internal/gui/parts" @@ -129,11 +130,12 @@ const ( keyWidth = "windowWidth" keyHeight = "windowHeight" keyLanguage = "language" + keyMemory = "memory" ) // ours is every key this program writes, which is what Forget removes and all // it removes. The folder picker's own keys stay: they are the toolkit's. -var ours = []string{keyDirectory, keyWidth, keyHeight, keyLanguage} +var ours = []string{keyDirectory, keyWidth, keyHeight, keyLanguage, keyMemory} func (s stored) Directory() string { return s.prefs.String(keyDirectory) } func (s stored) RememberDirectory(d string) { s.prefs.SetString(keyDirectory, d) } @@ -147,6 +149,15 @@ func (s stored) RememberLanguage(tag string) { s.prefs.SetString(keyLanguage, tag) } +func (s stored) Memory() string { return s.prefs.String(keyMemory) } +func (s stored) RememberMemory(setting string) { + if setting == "" { + s.prefs.RemoveValue(keyMemory) + return + } + s.prefs.SetString(keyMemory, setting) +} + func (s stored) Forget() { for _, key := range ours { s.prefs.RemoveValue(key) @@ -168,6 +179,8 @@ func (s stored) RememberSize(size fyne.Size) { // SettingsFolder is the toolkit's storage root for this application, which is // where its preferences.json lives - asked of the toolkit rather than put // together here, so it is the folder the file is really in. +func (d desktop) MachineMemory() (int64, error) { return core.MachineMemory() } + func (d desktop) SettingsFolder() string { return filepath.FromSlash(fyne.CurrentApp().Storage().RootURI().Path()) } diff --git a/internal/gui/text/locale/en.json b/internal/gui/text/locale/en.json index d718896d..6ba26057 100644 --- a/internal/gui/text/locale/en.json +++ b/internal/gui/text/locale/en.json @@ -364,6 +364,16 @@ "hash": "sha256-882e40f5910b", "other": "Manifest file name" }, + "FieldMemorySize": { + "description": "The name above a box somebody fills in.", + "hash": "sha256-4d5e4f1409c1", + "other": "Memory size" + }, + "FieldMemoryWay": { + "description": "The name above a box somebody fills in.", + "hash": "sha256-ac0c32657fb9", + "other": "Memory for a run" + }, "FieldNameTemplate": { "description": "The name above a box somebody fills in.", "hash": "sha256-6d00d6e32977", @@ -466,6 +476,11 @@ "hash": "sha256-986221f924c6", "other": "The record of what this run produced." }, + "HintMemorySize": { + "description": "The line under a field name, saying what the field does.", + "hash": "sha256-e11ecda0afb5", + "other": "8gb" + }, "HintNameTemplate": { "description": "The line under a field name, saying what the field does.", "hash": "sha256-c92ff0a07510", @@ -616,6 +631,51 @@ "hash": "sha256-0b5d14c5fc58", "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." }, + "MemoryAutoIs": { + "description": "Shown in the window. Carries one value, {{.Size}}, which has to stay spelled exactly that way.", + "hash": "sha256-3a039b136e1c", + "other": "Runs may take {{.Size}}, half of the memory this computer gives the program." + }, + "MemoryAutoUnknown": { + "description": "Shown in the window. Carries one value, {{.Size}}, which has to stay spelled exactly that way.", + "hash": "sha256-c1b8c2b64c84", + "other": "The memory of this computer could not be read, so runs take {{.Size}}, the most every run took before this setting." + }, + "MemoryCustomEmpty": { + "description": "Shown in the window.", + "hash": "sha256-f021f2e8d953", + "other": "Write a size such as 8gb. Until there is one, runs use Automatic." + }, + "MemoryCustomIs": { + "description": "Shown in the window.", + "hash": "sha256-269b4fa95f8d", + "other": "Runs may take the size above." + }, + "MemoryOffIs": { + "description": "Shown in the window.", + "hash": "sha256-2b45ce57da2e", + "other": "Runs take what they need. Pictures are not held back and the plan has no limit, so a very large run can be ended by the system without a word." + }, + "MemoryUnreadable": { + "description": "Shown in the window. Carries one value, {{.Value}}, which has to stay spelled exactly that way.", + "hash": "sha256-42d966183e49", + "other": "The memory setting kept before, {{.Value}}, could not be read. Runs use Automatic until you choose again." + }, + "MemoryWayAuto": { + "description": "Shown in the window.", + "hash": "sha256-d461a493a375", + "other": "Automatic" + }, + "MemoryWayCustom": { + "description": "Shown in the window.", + "hash": "sha256-494ca78f7374", + "other": "Custom" + }, + "MemoryWayOff": { + "description": "Shown in the window.", + "hash": "sha256-f7fcff0d8fea", + "other": "No limit" + }, "NotAWholeNumber": { "description": "Shown in the window. Carries these values, each of which has to stay spelled exactly that way: {{.Field}}, {{.Value}}.", "hash": "sha256-2537ac57d3c7", @@ -668,8 +728,8 @@ }, "PreferencesForgetWhat": { "description": "Shown on the Preferences screen.", - "hash": "sha256-f7c12f863d64", - "other": "Forget clears the three things above. This window then keeps nothing when it closes, and the next one opens the way the first one did." + "hash": "sha256-8145d3c07952", + "other": "Forget clears the four things above. This window then keeps nothing when it closes, and the next one opens the way the first one did." }, "PreferencesForgotten": { "description": "Shown on the Preferences screen.", @@ -678,8 +738,8 @@ }, "PreferencesKept": { "description": "Shown on the Preferences screen.", - "hash": "sha256-896c43e06cdb", - "other": "Between runs this window keeps three things: the output directory, the size of the window, and the language chosen above. The folder picker also keeps the last folder it showed. Nothing leaves this computer." + "hash": "sha256-d5cfc5e4bd88", + "other": "Between runs this window keeps four things: the output directory, the size of the window, and the language and the memory chosen above. The folder picker also keeps the last folder it showed. Nothing leaves this computer." }, "PreferencesKeptIn": { "description": "Shown on the Preferences screen.", @@ -691,6 +751,11 @@ "hash": "sha256-bc4174717ca7", "other": "Only the window changes language. The files it makes, recipes, manifests and the command line do not." }, + "PreferencesMemoryScope": { + "description": "Shown on the Preferences screen. Carries one value, {{.Floor}}, which has to stay spelled exactly that way.", + "hash": "sha256-847b853f524e", + "other": "How much memory one run may take, the plan and the files being written together. Pictures wait for room rather than all being held at once, and a plan larger than this is refused before anything is written. Under Automatic, a plan of up to {{.Floor}} is never refused. It applies from the next run, and nothing in the files changes." + }, "PreferencesMissing": { "description": "Shown on the Preferences screen. Carries one value, {{.Tag}}, which has to stay spelled exactly that way.", "hash": "sha256-7679a7684a73", @@ -821,6 +886,11 @@ "hash": "sha256-524730d883af", "other": "Notes for the manifest" }, + "SectionMemory": { + "description": "The heading over a group of fields.", + "hash": "sha256-c3963aedaac6", + "other": "Memory" + }, "SectionOutput": { "description": "The heading over a group of fields.", "hash": "sha256-b2439bcb8dee", @@ -918,8 +988,8 @@ }, "SubtitlePreferences": { "description": "The sentence under the heading at the top of a screen, saying what the screen is for.", - "hash": "sha256-0e310c7f4078", - "other": "How this window speaks, and what it keeps between runs. Nothing here changes the files it makes." + "hash": "sha256-836103012003", + "other": "How this window speaks, how much memory its runs may take, and what it keeps between runs. Nothing here changes the files it makes." }, "SubtitlePreset": { "description": "The sentence under the heading at the top of a screen, saying what the screen is for.", diff --git a/internal/gui/text/locale/pl.json b/internal/gui/text/locale/pl.json index 40491977..d4fe2f7d 100644 --- a/internal/gui/text/locale/pl.json +++ b/internal/gui/text/locale/pl.json @@ -72,6 +72,8 @@ "FieldLabel": { "hash": "sha256-98e8b12087c4", "other": "Etykieta w każdym pliku" }, "FieldLanguage": { "hash": "sha256-703df606ae88", "other": "Język okna" }, "FieldManifest": { "hash": "sha256-882e40f5910b", "other": "Nazwa pliku manifestu" }, + "FieldMemorySize": { "hash": "sha256-4d5e4f1409c1", "other": "Rozmiar pamięci" }, + "FieldMemoryWay": { "hash": "sha256-ac0c32657fb9", "other": "Pamięć na przebieg" }, "FieldNameTemplate": { "hash": "sha256-6d00d6e32977", "other": "Nazwy plików" }, "FieldOutputDir": { "hash": "sha256-c94c0d681c64", "other": "Katalog wyjściowy" }, "FieldPreset": { "hash": "sha256-7252e7ce0085", "other": "Preset" }, @@ -92,6 +94,7 @@ "HintFormat": { "hash": "sha256-f5616fe5a139", "other": "Jaki rodzaj pliku wytworzyć." }, "HintGroup": { "hash": "sha256-e95a20274902", "other": "Oznacza kilka partii jako jeden rodzaj przypadku." }, "HintManifest": { "hash": "sha256-986221f924c6", "other": "Zapis tego, co wytworzył przebieg." }, + "HintMemorySize": { "hash": "sha256-e11ecda0afb5", "other": "8gb" }, "HintNameTemplate": { "hash": "sha256-c92ff0a07510", "other": "Jak nazywa się każdy plik tej partii." }, "HintOutputDir": { "hash": "sha256-35dfac65eb8a", "other": "Dokąd trafiają pliki i manifest." }, "HintPreset": { "hash": "sha256-e86685eae6f6", "other": "Co testujesz." }, @@ -122,6 +125,15 @@ "ManifestNamed": { "hash": "sha256-f9540bb6e465", "other": "Manifest: {{.Name}}" }, "ManifestNotSaved": { "hash": "sha256-bb722052cb9e", "other": "pliki zostały zapisane, a manifestu nie udało się zapisać do {{.Path}}" }, "ManifestTooLargeToRead": { "hash": "sha256-0b5d14c5fc58", "other": "zapis tego przebiegu ma około {{.Size}}, a ta wersja czyta najwyżej {{.Limit}}, więc polecenia verify i cleanup nie zdołają go odczytać. Podziel przebieg, żeby każdy zapis dał się odczytać." }, + "MemoryAutoIs": { "hash": "sha256-3a039b136e1c", "other": "Przebiegi mogą wziąć {{.Size}}, połowę pamięci, którą ten komputer daje programowi." }, + "MemoryAutoUnknown": { "hash": "sha256-c1b8c2b64c84", "other": "Nie udało się odczytać pamięci tego komputera, więc przebiegi biorą {{.Size}}, czyli najwięcej, ile brał każdy przebieg przed tym ustawieniem." }, + "MemoryCustomEmpty": { "hash": "sha256-f021f2e8d953", "other": "Wpisz rozmiar, na przykład 8gb. Dopóki go nie ma, przebiegi używają ustawienia Automatycznie." }, + "MemoryCustomIs": { "hash": "sha256-269b4fa95f8d", "other": "Przebiegi mogą wziąć rozmiar podany wyżej." }, + "MemoryOffIs": { "hash": "sha256-2b45ce57da2e", "other": "Przebiegi biorą tyle, ile potrzebują. Obrazy nie są wstrzymywane, a plan nie ma limitu, więc bardzo duży przebieg może zostać zakończony przez system bez słowa." }, + "MemoryUnreadable": { "hash": "sha256-42d966183e49", "other": "Zapisanego wcześniej ustawienia pamięci, {{.Value}}, nie udało się odczytać. Przebiegi używają ustawienia Automatycznie, dopóki nie wybierzesz ponownie." }, + "MemoryWayAuto": { "hash": "sha256-d461a493a375", "other": "Automatycznie" }, + "MemoryWayCustom": { "hash": "sha256-494ca78f7374", "other": "Własna" }, + "MemoryWayOff": { "hash": "sha256-f7fcff0d8fea", "other": "Bez limitu" }, "NotAWholeNumber": { "hash": "sha256-2537ac57d3c7", "other": "{{.Field}} ma wartość {{.Value}}, która nie jest liczbą całkowitą. Wpisz same cyfry, na przykład 1 albo 500" }, "NoteBase": { "hash": "sha256-e48510060140", "other": "Z zaznaczonym polem najpierw idą pliki wybranego presetu, a partie poniżej są dodawane po nich. Bez niego partie są całym przepisem." }, "NoteManifestOnly": { "hash": "sha256-794d5d9e2043", "other": "Te pola opisują przypadek. Trafiają do manifestu i niczego nie zmieniają w plikach." }, @@ -132,11 +144,12 @@ "PlaceholderNotSet": { "hash": "sha256-1aef93991721", "other": "nie ustawiono" }, "PlaceholderNotStated": { "hash": "sha256-109df22c5a75", "other": "nie podano" }, "PlaceholderWorkedOut": { "hash": "sha256-195191b2dc3a", "other": "wyliczane z rozmiaru" }, - "PreferencesForgetWhat": { "hash": "sha256-f7c12f863d64", "other": "Zapomnij czyści trzy rzeczy powyżej. To okno nie zachowa potem niczego przy zamknięciu, a następne otworzy się tak jak pierwsze." }, + "PreferencesForgetWhat": { "hash": "sha256-8145d3c07952", "other": "Zapomnij czyści cztery rzeczy wymienione wyżej. To okno nie zapamięta wtedy nic przy zamknięciu, a następne otworzy się tak, jak otworzyło się pierwsze." }, "PreferencesForgotten": { "hash": "sha256-8e950dfcda31", "other": "Zapomniane. Następne okno otworzy się tak jak pierwsze." }, - "PreferencesKept": { "hash": "sha256-896c43e06cdb", "other": "Między uruchomieniami to okno zachowuje trzy rzeczy: katalog wyjściowy, rozmiar okna i język wybrany powyżej. Okno wyboru katalogu zachowuje też ostatni pokazany katalog. Nic nie opuszcza tego komputera." }, + "PreferencesKept": { "hash": "sha256-d5cfc5e4bd88", "other": "Między przebiegami to okno pamięta cztery rzeczy: katalog wyjściowy, rozmiar okna oraz wybrane wyżej język i pamięć. Okno wyboru folderu pamięta też ostatni pokazany folder. Nic nie opuszcza tego komputera." }, "PreferencesKeptIn": { "hash": "sha256-65ceb0c15ff4", "other": "Są przechowywane w tym folderze:" }, "PreferencesLanguageScope": { "hash": "sha256-bc4174717ca7", "other": "Język zmienia tylko okno, a nie pliki, które tworzy, przepisy, manifesty ani linię komend." }, + "PreferencesMemoryScope": { "hash": "sha256-847b853f524e", "other": "Ile pamięci może wziąć jeden przebieg, plan i zapisywane pliki razem. Obrazy czekają na miejsce, zamiast trzymać się w pamięci wszystkie naraz, a plan większy niż to ustawienie jest odrzucany, zanim cokolwiek zostanie zapisane. Przy ustawieniu Automatycznie plan do {{.Floor}} nigdy nie jest odrzucany. Działa od następnego przebiegu i nie zmienia niczego w plikach." }, "PreferencesMissing": { "hash": "sha256-7679a7684a73", "other": "Język wybrany wcześniej, {{.Tag}}, nie jest dostępny w tej wersji. Okno używa języka systemu, dopóki nie wybierzesz ponownie." }, "PreferencesNextStart": { "hash": "sha256-6fee24596599", "other": "Okno mówi wybranym językiem od następnego otwarcia." }, "PreferencesRestartBusy": { "hash": "sha256-1d940383b3e9", "other": "Trwa tworzenie plików. Uruchom ponownie, gdy się skończy." }, @@ -163,6 +176,7 @@ "SectionLanguage": { "hash": "sha256-a4fe65264ef7", "other": "Język" }, "SectionLicence": { "hash": "sha256-f3ec8e880a46", "other": "Licencja" }, "SectionManifestNotes": { "hash": "sha256-524730d883af", "other": "Uwagi do manifestu" }, + "SectionMemory": { "hash": "sha256-c3963aedaac6", "other": "Pamięć" }, "SectionOutput": { "hash": "sha256-b2439bcb8dee", "other": "Wyjście" }, "SectionPreset": { "hash": "sha256-ed65b5dde19f", "other": "Pytanie" }, "SectionRemembered": { "hash": "sha256-3969bfb60b8d", "other": "Co pamięta to okno" }, @@ -182,7 +196,7 @@ "SizeWayRange": { "hash": "sha256-8879adb25c7d", "other": "Zakres" }, "StoppedAfter": { "hash": "sha256-f08278015581", "other": "Zatrzymano. Zapisano: {{.Files}}. Manifest opisuje dokładnie te pliki." }, "SubtitleGenerate": { "hash": "sha256-e7dcd4001e28", "other": "Pliki jednego formatu i jednego rozmiaru, z manifestem, który mówi, jak system pod testem powinien na nie zareagować." }, - "SubtitlePreferences": { "hash": "sha256-0e310c7f4078", "other": "Jak mówi to okno i co zachowuje między uruchomieniami. Nic tutaj nie zmienia plików, które tworzy." }, + "SubtitlePreferences": { "hash": "sha256-836103012003", "other": "Jak to okno mówi, ile pamięci mogą wziąć jego przebiegi i co pamięta między przebiegami. Nic tutaj nie zmienia plików, które tworzy." }, "SubtitlePreset": { "hash": "sha256-7962df9b6a39", "other": "Gotowe zestawy plików, każdy zbudowany, by odpowiedzieć na jedno pytanie o system pod testem." }, "SubtitleRecipe": { "hash": "sha256-784e4d30ec24", "other": "Partie różnych formatów i rozmiarów, generowane razem w jednym przebiegu." }, "SubtitleTools": { "hash": "sha256-53bdfa16d586", "other": "Drobne czynności na plikach, które już masz. Każde narzędzie z tej zakładki jest też w linii poleceń, jako tfg tool." }, diff --git a/internal/gui/text/locale/said/en.json b/internal/gui/text/locale/said/en.json index 4cef848c..0654f29e 100644 --- a/internal/gui/text/locale/said/en.json +++ b/internal/gui/text/locale/said/en.json @@ -580,6 +580,11 @@ "hash": "sha256-54b8e801756c", "other": "the set needs a size one byte below the limit, and there is nothing below zero" }, + "core.CgroupMalformed": { + "description": "Said by internal/core - a refusal under the box it is about or at the foot of the form, or a note after a run. Carries {{.File}}, {{.Text}}, written the way the program writes them - keep each spelled exactly that way.", + "hash": "sha256-df81172914cc", + "other": "{{.File}} does not say what Linux writes there: {{.Text}}" + }, "core.DurationEmpty": { "description": "Said by internal/core - a refusal under the box it is about or at the foot of the form, or a note after a run.", "hash": "sha256-3a7508d80a33", @@ -620,15 +625,55 @@ "hash": "sha256-6dc38cd1cb85", "other": "{{.Error}}" }, + "core.MachineMemoryNone": { + "description": "Said by internal/core - a refusal under the box it is about or at the foot of the form, or a note after a run. Carries {{.Bytes}}, written the way the program writes them - keep each spelled exactly that way.", + "hash": "sha256-76987096904d", + "other": "the system said this machine has {{.Bytes}} B of memory" + }, + "core.MemoryEmpty": { + "description": "Said by internal/core - a refusal under the box it is about or at the foot of the form, or a note after a run.", + "hash": "sha256-4a0a99260fc6", + "other": "empty {{.Setting}}: write auto, off or a size such as 8gb" + }, + "core.MemoryNegative": { + "description": "Said by internal/core - a refusal under the box it is about or at the foot of the form, or a note after a run. Carries {{.Value}}, written the way the program writes them - keep each spelled exactly that way.", + "hash": "sha256-87bca52970de", + "other": "{{.Setting}} {{.Value}} is negative: write auto, off or a size such as 8gb" + }, + "core.MemoryNotASize": { + "description": "Said by internal/core - a refusal under the box it is about or at the foot of the form, or a note after a run. Carries {{.Value}}, written the way the program writes them - keep each spelled exactly that way.", + "hash": "sha256-3afd7272e5a2", + "other": "{{.Setting}} {{.Value}} is not a size: write one such as 8gb" + }, + "core.MemoryUnknown": { + "description": "Said by internal/core - a refusal under the box it is about or at the foot of the form, or a note after a run. Carries {{.Value}}, written the way the program writes them - keep each spelled exactly that way.", + "hash": "sha256-c0a095315898", + "other": "{{.Setting}} {{.Value}} is not a setting this tool knows: write auto for half of this machine's memory, off for no limit, or a size such as 8gb" + }, + "core.MemoryZero": { + "description": "Said by internal/core - a refusal under the box it is about or at the foot of the form, or a note after a run. Carries {{.Value}}, written the way the program writes them - keep each spelled exactly that way.", + "hash": "sha256-1eb04d24e00d", + "other": "{{.Setting}} {{.Value}} leaves no room at all: write off for no limit, or a size such as 8gb" + }, "core.PlanTooLargeFix": { "description": "Said by internal/core - a refusal under the box it is about or at the foot of the form, or a note after a run.", - "hash": "sha256-cb36e3de1bfb", - "other": "Ask for fewer files, or make each one cheaper to plan - fewer pages, fewer entries - or split the work into several runs" + "hash": "sha256-1cd8766a17ef", + "other": "Ask for fewer files, or make each one cheaper to plan - fewer pages, fewer entries - or split the work into several runs. Or give the run more memory through the memory setting" + }, + "core.PlanTooLargeWhyAuto": { + "description": "Said by internal/core - a refusal under the box it is about or at the foot of the form, or a note after a run. Carries {{.Ceiling}}, {{.Floor}}, written the way the program writes them - keep each spelled exactly that way.", + "hash": "sha256-2db0dbe7ea11", + "other": "the whole plan is held in memory before anything is written, and this run gives it {{.Ceiling}}, half of what this machine gives the program and never under {{.Floor}} - how much a file costs to plan depends on the format, so a ceiling on the number of files alone cannot see this" }, - "core.PlanTooLargeWhy": { + "core.PlanTooLargeWhySet": { "description": "Said by internal/core - a refusal under the box it is about or at the foot of the form, or a note after a run. Carries {{.Ceiling}}, written the way the program writes them - keep each spelled exactly that way.", - "hash": "sha256-52bd0c2594f6", - "other": "the whole plan is held in memory before anything is written, and this build works to a ceiling of {{.Ceiling}} for it - how much a file costs to plan depends on the format, so a ceiling on the number of files alone cannot see this" + "hash": "sha256-4ebcd0bb8055", + "other": "the whole plan is held in memory before anything is written, and the memory setting of this run gives it {{.Ceiling}} - how much a file costs to plan depends on the format, so a ceiling on the number of files alone cannot see this" + }, + "core.PlanTooLargeWhyUnknown": { + "description": "Said by internal/core - a refusal under the box it is about or at the foot of the form, or a note after a run. Carries {{.Ceiling}}, written the way the program writes them - keep each spelled exactly that way.", + "hash": "sha256-76ffcdf2311b", + "other": "the whole plan is held in memory before anything is written, and this run gives it {{.Ceiling}} because the memory of this machine could not be read - how much a file costs to plan depends on the format, so a ceiling on the number of files alone cannot see this" }, "core.RangeBackwards": { "description": "Said by internal/core - a refusal under the box it is about or at the foot of the form, or a note after a run. Carries {{.From}}, {{.To}}, {{.Low}}, {{.High}}, written the way the program writes them - keep each spelled exactly that way.", @@ -948,6 +993,16 @@ "hash": "sha256-7a1261279b19", "other": "cannot write the instructions to {{.Path}}: {{.Cause}}" }, + "engine.MemoryOverrun": { + "description": "Said by internal/engine - a refusal under the box it is about or at the foot of the form, or a note after a run. Carries {{.Name}}, {{.Holds}}, {{.Room}}, written the way the program writes them - keep each spelled exactly that way.", + "hash": "sha256-168a887a89fa", + "other": "{{.Name}} holds about {{.Holds}} while it is written, more than the {{.Room}} the memory setting left the files being written, so it was written on its own and the run went past the setting until it finished" + }, + "engine.MemoryUnknown": { + "description": "Said by internal/engine - a refusal under the box it is about or at the foot of the form, or a note after a run. Carries {{.Cause}}, {{.Limit}}, written the way the program writes them - keep each spelled exactly that way.", + "hash": "sha256-25e95b18a15e", + "other": "the memory of this machine could not be read ({{.Cause}}), so this run worked to {{.Limit}}, the most any run took before the memory setting existed. Set the memory setting yourself to choose the limit" + }, "engine.NameAbsolute": { "description": "Said by internal/engine - a refusal under the box it is about or at the foot of the form, or a note after a run. Carries {{.Where}}, {{.Name}}, written the way the program writes them - keep each spelled exactly that way.", "hash": "sha256-2dc5711eaa7d", diff --git a/internal/gui/text/locale/said/pl.json b/internal/gui/text/locale/said/pl.json index 298da2d3..6a43cba2 100644 --- a/internal/gui/text/locale/said/pl.json +++ b/internal/gui/text/locale/said/pl.json @@ -115,6 +115,7 @@ "core.BoundaryTooSmall": { "hash": "sha256-a0fd66f30b3f", "other": "{{.Why}}. {{.Fix}}" }, "core.BoundaryTooSmallFix": { "hash": "sha256-d3ccef8f1f9d", "other": "Użyj limitu co najmniej 1 B" }, "core.BoundaryTooSmallWhy": { "hash": "sha256-54b8e801756c", "other": "zestaw potrzebuje rozmiaru o bajt mniejszego od limitu, a poniżej zera nic nie ma" }, + "core.CgroupMalformed": { "hash": "sha256-df81172914cc", "other": "{{.File}} nie mówi tego, co Linux tam zapisuje: {{.Text}}" }, "core.DurationEmpty": { "hash": "sha256-3a7508d80a33", "other": "pusty czas trwania: wpisz go tak jak 10s, 1m30s albo 500ms" }, "core.DurationNoUnit": { "hash": "sha256-6f7e4e89b31d", "other": "{{.Value}} nie ma jednostki: wpisz 10s dla sekund albo 10ms dla milisekund, bo sama liczba może znaczyć jedno albo drugie" }, "core.DurationNotALength": { "hash": "sha256-1b6907777cb3", "other": "{{.Value}} nie jest czasem trwania: wpisz go tak jak 10s, 1m30s albo 500ms. Znak, wykładnik czy przecinek są odrzucane, a nie zgadywane" }, @@ -123,8 +124,16 @@ "core.DurationTooLong": { "hash": "sha256-06c88d98ae65", "other": "{{.Value}} jest za długi, żeby policzyć go w milisekundach" }, "core.DurationUnknownUnit": { "hash": "sha256-96f50400bd65", "other": "{{.Value}} ma nieznaną jednostkę {{.Unit}}: użyj h, m, s albo ms" }, "core.Error": { "hash": "sha256-6dc38cd1cb85", "other": "{{.Error}}" }, - "core.PlanTooLargeFix": { "hash": "sha256-cb36e3de1bfb", "other": "Poproś o mniej plików albo o tańsze w planowaniu - mniej stron, mniej wpisów - albo podziel pracę na kilka przebiegów" }, - "core.PlanTooLargeWhy": { "hash": "sha256-52bd0c2594f6", "other": "cały plan jest trzymany w pamięci, zanim cokolwiek zostanie zapisane, a ta wersja ma na niego sufit {{.Ceiling}} - ile kosztuje zaplanowanie pliku, zależy od formatu, więc sam sufit liczby plików tego nie widzi" }, + "core.MachineMemoryNone": { "hash": "sha256-76987096904d", "other": "system podał, że ta maszyna ma {{.Bytes}} B pamięci" }, + "core.MemoryEmpty": { "hash": "sha256-4a0a99260fc6", "other": "pole {{.Setting}} jest puste: wpisz auto, off albo rozmiar, na przykład 8gb" }, + "core.MemoryNegative": { "hash": "sha256-87bca52970de", "other": "{{.Setting}} {{.Value}} jest ujemna: wpisz auto, off albo rozmiar, na przykład 8gb" }, + "core.MemoryNotASize": { "hash": "sha256-3afd7272e5a2", "other": "{{.Setting}} {{.Value}} to nie jest rozmiar: wpisz go, na przykład 8gb" }, + "core.MemoryUnknown": { "hash": "sha256-c0a095315898", "other": "{{.Setting}} {{.Value}} to nie jest ustawienie, które ten program zna: wpisz auto, żeby wziąć połowę pamięci tej maszyny, off, żeby nie mieć limitu, albo rozmiar, na przykład 8gb" }, + "core.MemoryZero": { "hash": "sha256-1eb04d24e00d", "other": "{{.Setting}} {{.Value}} nie zostawia żadnego miejsca: wpisz off, żeby nie mieć limitu, albo rozmiar, na przykład 8gb" }, + "core.PlanTooLargeFix": { "hash": "sha256-1cd8766a17ef", "other": "Poproś o mniej plików albo o tańsze w planowaniu - mniej stron, mniej wpisów - albo podziel pracę na kilka przebiegów. Możesz też dać przebiegowi więcej pamięci w ustawieniu pamięci" }, + "core.PlanTooLargeWhyAuto": { "hash": "sha256-2db0dbe7ea11", "other": "cały plan jest trzymany w pamięci, zanim cokolwiek zostanie zapisane, a ten przebieg daje mu {{.Ceiling}}, połowę tego, co ta maszyna daje programowi, i nigdy mniej niż {{.Floor}} - ile kosztuje zaplanowanie pliku, zależy od formatu, więc sam sufit liczby plików tego nie widzi" }, + "core.PlanTooLargeWhySet": { "hash": "sha256-4ebcd0bb8055", "other": "cały plan jest trzymany w pamięci, zanim cokolwiek zostanie zapisane, a ustawienie pamięci tego przebiegu daje mu {{.Ceiling}} - ile kosztuje zaplanowanie pliku, zależy od formatu, więc sam sufit liczby plików tego nie widzi" }, + "core.PlanTooLargeWhyUnknown": { "hash": "sha256-76ffcdf2311b", "other": "cały plan jest trzymany w pamięci, zanim cokolwiek zostanie zapisane, a ten przebieg daje mu {{.Ceiling}}, bo nie udało się odczytać pamięci tej maszyny - ile kosztuje zaplanowanie pliku, zależy od formatu, więc sam sufit liczby plików tego nie widzi" }, "core.RangeBackwards": { "hash": "sha256-1d0ff9d1af62", "other": "zakres biegnie wstecz, od {{.From}} B w dół do {{.To}} B. Mniejszy koniec idzie pierwszy, a zakres biegnący wstecz nie zawiera żadnego rozmiaru. Zapisz go jako {{.Low}}-{{.High}}" }, "core.RangeNotARange": { "hash": "sha256-ec7fa6194ef2", "other": "{{.Value}} nie jest zakresem. Zakres to dokładnie dwa rozmiary z jednym łącznikiem między nimi, na przykład 1kb-8kb" }, "core.SizeBelowZero": { "hash": "sha256-a2509071b044", "other": "plik nie może być mniejszy niż zero bajtów" }, @@ -188,6 +197,8 @@ "engine.IDTwice": { "hash": "sha256-724959681477", "other": "nazwa partii {{.ID}} występuje dwa razy" }, "engine.IDTwiceWhy": { "hash": "sha256-ded29dfee98c", "other": "nazwy wskazują partie, więc powtórzenie jest błędem, a nie cichym nadpisaniem" }, "engine.InstructionsNotWritten": { "hash": "sha256-7a1261279b19", "other": "nie można zapisać instrukcji do {{.Path}}: {{.Cause}}" }, + "engine.MemoryOverrun": { "hash": "sha256-168a887a89fa", "other": "{{.Name}} zajmuje przy zapisie około {{.Holds}}, więcej niż {{.Room}}, które ustawienie pamięci zostawiło zapisywanym plikom, więc został zapisany sam, a przebieg przekroczył ustawienie, dopóki go nie skończył" }, + "engine.MemoryUnknown": { "hash": "sha256-25e95b18a15e", "other": "nie udało się odczytać pamięci tej maszyny ({{.Cause}}), więc ten przebieg wziął {{.Limit}}, czyli najwięcej, ile brał każdy przebieg, zanim powstało ustawienie pamięci. Ustaw pamięć sam, żeby wybrać limit" }, "engine.NameAbsolute": { "hash": "sha256-2dc5711eaa7d", "other": "{{.Where}} daje ścieżkę bezwzględną {{.Name}}" }, "engine.NameAbsoluteWhy": { "hash": "sha256-61f6a0c76deb", "other": "przepis nie niesie ścieżek bezwzględnych, bo wtedy działa tylko na maszynie, na której go napisano" }, "engine.NameColon": { "hash": "sha256-9346cfb2e4a8", "other": "{{.Where}} daje nazwę {{.Name}}, w której jest dwukropek" }, diff --git a/internal/gui/text/preferences.go b/internal/gui/text/preferences.go new file mode 100644 index 00000000..e0401efa --- /dev/null +++ b/internal/gui/text/preferences.go @@ -0,0 +1,146 @@ +package text + +import "github.com/donislawdev/TestingFilesGenerator/internal/core" + +// The words of the Preferences screen, out of screens.go on 2026-10-27 when +// the Memory section took that file past the ceiling on its length - the +// screen's sentences are one part of what the file held, and it is split by +// what the parts do. + +// The Preferences screen: which language the window speaks, and what it keeps +// between runs. Nothing on it changes the files the window makes - a setting +// that did would be an input the recipe does not carry, and the command the +// window offers to show would make different files (docs/USTAWIENIA-2026-09-29.md +// section 2). +// +// "Preferences" and not "Settings" for the tab, because Settings is already the +// section of the form where a format's own settings stand, and two places +// called the same would read as one. +func TabPreferences() string { return say("TabPreferences", "Preferences") } + +func SubtitlePreferences() string { + return say("SubtitlePreferences", "How this window speaks, how much memory its runs may take, and what it keeps between runs. Nothing here changes the files it makes.") +} + +func SectionLanguage() string { return say("SectionLanguage", "Language") } +func SectionMemory() string { return say("SectionMemory", "Memory") } +func FieldMemoryWay() string { return say("FieldMemoryWay", "Memory for a run") } +func FieldMemorySize() string { return say("FieldMemorySize", "Memory size") } +func HintMemorySize() string { return say("HintMemorySize", "8gb") } +func MemoryWayAuto() string { return say("MemoryWayAuto", "Automatic") } +func MemoryWayCustom() string { return say("MemoryWayCustom", "Custom") } +func MemoryWayOff() string { return say("MemoryWayOff", "No limit") } + +// PreferencesMemoryScope says what the memory setting is for, under it. The +// floor of the plan under Automatic is said from the constant that sets it, +// the way --help says it. +func PreferencesMemoryScope() string { + return sayf("PreferencesMemoryScope", "How much memory one run may take, the plan and the files being written together. Pictures wait for room rather than all being held at once, and a plan larger than this is refused before anything is written. Under Automatic, a plan of up to {{.Floor}} is never refused. It applies from the next run, and nothing in the files changes.", + map[string]any{"Floor": HumanBytes(core.MaxPlanBytes)}) +} + +// MemoryAutoIs is what Automatic comes to on this computer. +func MemoryAutoIs(size string) string { + return sayf("MemoryAutoIs", "Runs may take {{.Size}}, half of the memory this computer gives the program.", map[string]any{"Size": size}) +} + +// MemoryAutoUnknown is Automatic on a computer that could not be asked. +func MemoryAutoUnknown(size string) string { + return sayf("MemoryAutoUnknown", "The memory of this computer could not be read, so runs take {{.Size}}, the most every run took before this setting.", map[string]any{"Size": size}) +} + +func MemoryCustomIs() string { + return say("MemoryCustomIs", "Runs may take the size above.") +} + +func MemoryCustomEmpty() string { + return say("MemoryCustomEmpty", "Write a size such as 8gb. Until there is one, runs use Automatic.") +} + +func MemoryOffIs() string { + return say("MemoryOffIs", "Runs take what they need. Pictures are not held back and the plan has no limit, so a very large run can be ended by the system without a word.") +} + +// MemoryUnreadable is a kept setting that does not read as one. +func MemoryUnreadable(value string) string { + return sayf("MemoryUnreadable", "The memory setting kept before, {{.Value}}, could not be read. Runs use Automatic until you choose again.", map[string]any{"Value": value}) +} +func SectionRemembered() string { return say("SectionRemembered", "What this window remembers") } +func FieldLanguage() string { return say("FieldLanguage", "Window language") } + +// LanguageName is what the language of a catalogue is called in that language +// itself. Every catalogue carries it, and it is how that language is listed on +// the Preferences screen - so a person who cannot read the language the window +// speaks now can still find their own. +func LanguageName() string { return say("LanguageName", "English") } + +// ChoiceSameAsSystem is the first entry of the language list, and what nobody +// having chosen means. It names the language that choice gives on this machine, +// because "the system" alone does not say whether that is a language this +// window carries. +func ChoiceSameAsSystem(lang string) string { + return sayf("ChoiceSameAsSystem", "Same as the system ({{.Language}})", map[string]any{"Language": lang}) +} + +// PreferencesLanguageScope says what the language reaches, because a list of +// languages invites the question whether the files will be in it too. +func PreferencesLanguageScope() string { + return say("PreferencesLanguageScope", "Only the window changes language. The files it makes, recipes, manifests and the command line do not.") +} + +// PreferencesNextStart is said once a language other than the one on screen is +// chosen. A choice that changed nothing visible and said nothing would read as +// a list that does not work. +func PreferencesNextStart() string { + return say("PreferencesNextStart", "The window speaks the chosen language from the next time it opens.") +} + +func ButtonRestart() string { return say("ButtonRestart", "Restart now") } + +// PreferencesRestartClears is beside the button, because the form on the other +// tabs is not kept across a restart and a person who typed a recipe there +// should know before pressing, not after. +func PreferencesRestartClears() string { + return say("PreferencesRestartClears", "Restarting closes this window and opens it again. Anything typed on the other tabs is cleared.") +} + +// PreferencesRestartBusy stands in for the button's use while files are being +// made: closing the window stops the run (docs/GUI.md G7), and a restart must +// not be a way to stop one without saying so. +func PreferencesRestartBusy() string { + return say("PreferencesRestartBusy", "Files are being made. Restart once they are done.") +} + +// PreferencesRestartFailed is the four parts of D6: what failed, why, and what +// still works - the choice is saved, so opening the window by hand gives it. +func PreferencesRestartFailed(reason string) string { + return sayf("PreferencesRestartFailed", "The window could not start again: {{.Reason}}. The language chosen is saved. Open the program again to see it.", map[string]any{"Reason": reason}) +} + +// PreferencesMissing is said when a language chosen before is not in this +// version. Untouchable rule 6: the window does not quietly speak something +// else. +func PreferencesMissing(tag string) string { + return sayf("PreferencesMissing", "The language chosen before, {{.Tag}}, is not in this version. The window follows the system until you choose again.", map[string]any{"Tag": tag}) +} + +// PreferencesKept says what is kept, in words, and that it stays on this +// computer. The folder picker's own memory is named as well: it writes into the +// same place, and a list of what is kept that left it out would be untrue. +func PreferencesKept() string { + return say("PreferencesKept", "Between runs this window keeps four things: the output directory, the size of the window, and the language and the memory chosen above. The folder picker also keeps the last folder it showed. Nothing leaves this computer.") +} + +func PreferencesKeptIn() string { return say("PreferencesKeptIn", "They are kept in this folder:") } + +func ButtonForget() string { return say("ButtonForget", "Forget") } + +// PreferencesForgetWhat is beside the button, so it says before the press what +// the press does and what it leaves alone. +func PreferencesForgetWhat() string { + return say("PreferencesForgetWhat", "Forget clears the four things above. This window then keeps nothing when it closes, and the next one opens the way the first one did.") +} + +func PreferencesForgotten() string { + return say("PreferencesForgotten", "Forgotten. The next window opens the way the first one did.") +} diff --git a/internal/gui/text/screens.go b/internal/gui/text/screens.go index fdda3cc5..a2fc9b2c 100644 --- a/internal/gui/text/screens.go +++ b/internal/gui/text/screens.go @@ -798,102 +798,6 @@ func DetailGitHub() string { // reason SupportURL gives. const RepositoryURL = legal.Repository -// The Preferences screen: which language the window speaks, and what it keeps -// between runs. Nothing on it changes the files the window makes - a setting -// that did would be an input the recipe does not carry, and the command the -// window offers to show would make different files (docs/USTAWIENIA-2026-09-29.md -// section 2). -// -// "Preferences" and not "Settings" for the tab, because Settings is already the -// section of the form where a format's own settings stand, and two places -// called the same would read as one. -func TabPreferences() string { return say("TabPreferences", "Preferences") } - -func SubtitlePreferences() string { - return say("SubtitlePreferences", "How this window speaks, and what it keeps between runs. Nothing here changes the files it makes.") -} - -func SectionLanguage() string { return say("SectionLanguage", "Language") } -func SectionRemembered() string { return say("SectionRemembered", "What this window remembers") } -func FieldLanguage() string { return say("FieldLanguage", "Window language") } - -// LanguageName is what the language of a catalogue is called in that language -// itself. Every catalogue carries it, and it is how that language is listed on -// the Preferences screen - so a person who cannot read the language the window -// speaks now can still find their own. -func LanguageName() string { return say("LanguageName", "English") } - -// ChoiceSameAsSystem is the first entry of the language list, and what nobody -// having chosen means. It names the language that choice gives on this machine, -// because "the system" alone does not say whether that is a language this -// window carries. -func ChoiceSameAsSystem(lang string) string { - return sayf("ChoiceSameAsSystem", "Same as the system ({{.Language}})", map[string]any{"Language": lang}) -} - -// PreferencesLanguageScope says what the language reaches, because a list of -// languages invites the question whether the files will be in it too. -func PreferencesLanguageScope() string { - return say("PreferencesLanguageScope", "Only the window changes language. The files it makes, recipes, manifests and the command line do not.") -} - -// PreferencesNextStart is said once a language other than the one on screen is -// chosen. A choice that changed nothing visible and said nothing would read as -// a list that does not work. -func PreferencesNextStart() string { - return say("PreferencesNextStart", "The window speaks the chosen language from the next time it opens.") -} - -func ButtonRestart() string { return say("ButtonRestart", "Restart now") } - -// PreferencesRestartClears is beside the button, because the form on the other -// tabs is not kept across a restart and a person who typed a recipe there -// should know before pressing, not after. -func PreferencesRestartClears() string { - return say("PreferencesRestartClears", "Restarting closes this window and opens it again. Anything typed on the other tabs is cleared.") -} - -// PreferencesRestartBusy stands in for the button's use while files are being -// made: closing the window stops the run (docs/GUI.md G7), and a restart must -// not be a way to stop one without saying so. -func PreferencesRestartBusy() string { - return say("PreferencesRestartBusy", "Files are being made. Restart once they are done.") -} - -// PreferencesRestartFailed is the four parts of D6: what failed, why, and what -// still works - the choice is saved, so opening the window by hand gives it. -func PreferencesRestartFailed(reason string) string { - return sayf("PreferencesRestartFailed", "The window could not start again: {{.Reason}}. The language chosen is saved. Open the program again to see it.", map[string]any{"Reason": reason}) -} - -// PreferencesMissing is said when a language chosen before is not in this -// version. Untouchable rule 6: the window does not quietly speak something -// else. -func PreferencesMissing(tag string) string { - return sayf("PreferencesMissing", "The language chosen before, {{.Tag}}, is not in this version. The window follows the system until you choose again.", map[string]any{"Tag": tag}) -} - -// PreferencesKept says what is kept, in words, and that it stays on this -// computer. The folder picker's own memory is named as well: it writes into the -// same place, and a list of what is kept that left it out would be untrue. -func PreferencesKept() string { - return say("PreferencesKept", "Between runs this window keeps three things: the output directory, the size of the window, and the language chosen above. The folder picker also keeps the last folder it showed. Nothing leaves this computer.") -} - -func PreferencesKeptIn() string { return say("PreferencesKeptIn", "They are kept in this folder:") } - -func ButtonForget() string { return say("ButtonForget", "Forget") } - -// PreferencesForgetWhat is beside the button, so it says before the press what -// the press does and what it leaves alone. -func PreferencesForgetWhat() string { - return say("PreferencesForgetWhat", "Forget clears the three things above. This window then keeps nothing when it closes, and the next one opens the way the first one did.") -} - -func PreferencesForgotten() string { - return say("PreferencesForgotten", "Forgotten. The next window opens the way the first one did.") -} - // The Tools tab - docs/NARZEDZIA-SUMY-2026-09-29.md. What each tool says about // itself comes from its declaration through registry.go. These are the words // of the screen around it. diff --git a/internal/gui/window/generate.go b/internal/gui/window/generate.go index fc50dba4..bd841478 100644 --- a/internal/gui/window/generate.go +++ b/internal/gui/window/generate.go @@ -156,6 +156,13 @@ type Host interface { // names what "Same as the system" comes to on this machine with it. SystemLanguage() string + // MachineMemory is how much memory this machine gives the program, which + // the memory setting's auto halves - core.MachineMemory in the real + // window. Asked through here so the Preferences screen can say what auto + // comes to on this machine, and a guard can draw that screen with a number + // of its own rather than with the memory of whichever runner it is on. + MachineMemory() (int64, error) + // RestartWhenClosed asks for this program to start again once this window // has closed - the second half of Restart now, after the screens have // closed the window the way a person closing it does. Starting the new @@ -230,7 +237,7 @@ type Generate struct { // NewGenerate builds the screen. links are the buttons to the other screens. func NewGenerate(host Host, links ...fyne.CanvasObject) *Generate { - g := &Generate{runner: newRunner(host.Later), host: host, tips: parts.NewTips(), settingsFolded: true} + g := &Generate{runner: newRunner(host.Later, host.Remembered, host.MachineMemory), host: host, tips: parts.NewTips(), settingsFolded: true} g.runner.settle = watchedSettle(host, g.runner, g.settle) g.runner.offer.through(host) // This screen is one target and draws its boxes under the bare key, so a diff --git a/internal/gui/window/preferences.go b/internal/gui/window/preferences.go index b5a73ae0..35eed816 100644 --- a/internal/gui/window/preferences.go +++ b/internal/gui/window/preferences.go @@ -11,7 +11,8 @@ import ( // Preferences is the screen of how the window speaks and what it keeps between // runs - docs/USTAWIENIA-2026-09-29.md, the owner's decisions of that day. // -// Nothing on it changes the files the window makes. A setting that did would +// Nothing on it changes the files the window makes - the memory a run may take +// changes when files are written and never what is in them. A setting that did would // be an input the recipe does not carry, and the command the window offers to // show would then make different files from the ones on screen - so anything // that shapes a file belongs in the form, not here. @@ -41,6 +42,9 @@ type Preferences struct { forget *parts.Button forgotten *parts.QuietText + // memory is the section that says how much memory a run may take. + memory *memoryPreference + object fyne.CanvasObject } @@ -79,7 +83,8 @@ func NewPreferences(h Host, leave func(), busy func() bool) *Preferences { p.forget)), p.forgotten, ) - page := parts.Screen(parts.Titled(text.TabPreferences(), text.SubtitlePreferences()), language, remembered) + p.memory = newMemoryPreference(h) + page := parts.Screen(parts.Titled(text.TabPreferences(), text.SubtitlePreferences()), language, p.memory.object, remembered) p.object = container.NewVScroll(page) return p } @@ -127,6 +132,7 @@ func (p *Preferences) restartNow() { func (p *Preferences) forgetNow() { p.host.Remembered().Forget() p.show(p.choices.optionFor("")) + p.memory.open("") p.forgotten.SetText(text.PreferencesForgotten()) p.forget.Disable() p.refresh() diff --git a/internal/gui/window/prefmemory.go b/internal/gui/window/prefmemory.go new file mode 100644 index 00000000..2a6e4d6f --- /dev/null +++ b/internal/gui/window/prefmemory.go @@ -0,0 +1,182 @@ +package window + +import ( + "fyne.io/fyne/v2" + + "github.com/donislawdev/TestingFilesGenerator/internal/core" + "github.com/donislawdev/TestingFilesGenerator/internal/gui/parts" + "github.com/donislawdev/TestingFilesGenerator/internal/gui/text" +) + +// memoryPreference is the Memory section of the Preferences screen: how much +// memory a run may take - docs/STABILITY-REVIEW-2026-10-26.md section 10.8, +// the owner's decisions of 2026-10-27. +// +// The window's half of --memory. Three ways on one switch - Automatic, Custom +// and No limit - and a box for the size that is on the screen only while +// Custom is chosen, the way the form shows one of its three boxes for a size. +// What is chosen is kept at once, as the language is, and read at the start of +// every run (runner.kept), so it applies from the next run and never to one +// already going. +// +// A section of its own type from the start (GUI rule 9): it has a switch, a +// box, a line that says what the choice comes to on this machine, and the rule +// that opening the screen writes nothing. +type memoryPreference struct { + host Host + fields *parts.Fields + way *parts.Segments + size *parts.Entry + box fyne.CanvasObject + // state is what the chosen way comes to on this machine, or why a kept + // value was not used. Never empty, so the section does not move when it + // changes - GUI rule 3. + state *parts.QuietText + // opening is true while the screen puts the kept value on the controls, + // when their callbacks must not write it back. + opening bool + object fyne.CanvasObject +} + +func newMemoryPreference(h Host) *memoryPreference { + m := &memoryPreference{host: h, fields: parts.NewFields(), size: parts.NewEntry()} + // The callbacks go on before the controls are registered, because the + // fields chain what a control already does rather than replace it. + m.way = parts.NewSegments(memoryWayNames(), m.chose) + m.size.OnChanged = m.typed + m.box = m.fields.Add(core.SettingMemory, text.FieldMemorySize(), text.HintMemorySize(), parts.NoDetail, parts.Numeric(m.size)) + m.state = parts.NewQuietText("") + m.state.Wrapping = fyne.TextWrapWord + m.open(h.Remembered().Memory()) + + m.object = parts.Section(text.SectionMemory(), + parts.Grid(m.fields.Named(text.FieldMemoryWay(), parts.NoDetail, m.way), m.box), + parts.Prose(text.PreferencesMemoryScope()), + m.state, + ) + return m +} + +// The three ways, in one order, named once. +func memoryWayNames() []string { + return []string{text.MemoryWayAuto(), text.MemoryWayCustom(), text.MemoryWayOff()} +} + +// open puts a kept setting on the controls without saving anything. A kept +// value that does not read is shown as Automatic, which is what a run does with +// it, and the line under the switch says what it was - saving Automatic over it +// would erase the person's choice without a press, untouchable rule 7. +// +// The box is emptied first. Forget opens the screen again on nothing kept, and +// a size left standing in the box from before would come back the moment +// Custom is chosen - kept again by the choice, after the person asked for it +// to be forgotten. +func (m *memoryPreference) open(kept string) { + m.opening = true + defer func() { m.opening = false }() + m.size.SetText("") + m.fields.Clear(core.SettingMemory) + parsed, err := core.ParseMemory(kept) + unreadable := "" + switch { + case kept == "": + m.way.SetSelected(text.MemoryWayAuto()) + case err != nil: + m.way.SetSelected(text.MemoryWayAuto()) + unreadable = kept + case parsed.IsOff(): + m.way.SetSelected(text.MemoryWayOff()) + default: + m.way.SetSelected(text.MemoryWayCustom()) + m.size.SetText(parsed.String()) + } + m.show(unreadable) +} + +// chose keeps the way picked on the switch. Custom keeps the size in the box +// when there is one, and Automatic until there is - which the line says. +func (m *memoryPreference) chose(string) { + if m.opening { + return + } + m.fields.Clear(core.SettingMemory) + m.host.Remembered().RememberMemory(keptAs(m.chosen())) + m.show("") +} + +// typed keeps a size as it is typed, and marks the box while what is in it is +// not one - the run then uses Automatic, and the line says so. +func (m *memoryPreference) typed(string) { + if m.opening || m.way.Selected != text.MemoryWayCustom() { + return + } + if _, err := core.ParseMemorySize(m.size.Text); err != nil { + m.fields.Mark(core.SettingMemory, err) + } else { + m.fields.Clear(core.SettingMemory) + } + m.host.Remembered().RememberMemory(keptAs(m.chosen())) + m.show("") +} + +// chosen is the setting the controls say, as a run will read it. +func (m *memoryPreference) chosen() core.Memory { + switch m.way.Selected { + case text.MemoryWayOff(): + return core.MemoryOff() + case text.MemoryWayCustom(): + if size, err := core.ParseMemorySize(m.size.Text); err == nil { + return size + } + } + return core.Memory{} +} + +// show leaves the box on the screen for Custom alone and says what the choice +// comes to. +func (m *memoryPreference) show(unreadable string) { + if m.way.Selected == text.MemoryWayCustom() { + m.box.Show() + } else { + m.box.Hide() + } + m.state.SetText(m.sentence(unreadable)) +} + +// sentence is the line under the switch. +func (m *memoryPreference) sentence(unreadable string) string { + switch { + case unreadable != "": + return text.MemoryUnreadable(unreadable) + case m.way.Selected == text.MemoryWayOff(): + return text.MemoryOffIs() + case m.way.Selected == text.MemoryWayCustom() && m.chosen().IsAuto(): + return text.MemoryCustomEmpty() + case m.way.Selected == text.MemoryWayCustom(): + return text.MemoryCustomIs() + } + limits := core.Memory{}.On(m.host.MachineMemory) + if limits.Unknown != nil { + return text.MemoryAutoUnknown(text.HumanBytes(limits.Run)) + } + return text.MemoryAutoIs(text.HumanBytes(limits.Run)) +} + +// keptAs is the text a setting is kept as: nothing for Automatic, so the value +// is removed rather than written, the way a language left to the system is. +func keptAs(m core.Memory) string { + if m.IsAuto() { + return "" + } + return m.String() +} + +// memoryOf is the kept setting as a run reads it. A value that does not read is +// Automatic, the same answer the screen shows for it. +func memoryOf(kept Remembered) core.Memory { + m, err := core.ParseMemory(kept.Memory()) + if err != nil { + return core.Memory{} + } + return m +} diff --git a/internal/gui/window/preset.go b/internal/gui/window/preset.go index 18e9aa0a..c81c3095 100644 --- a/internal/gui/window/preset.go +++ b/internal/gui/window/preset.go @@ -58,7 +58,7 @@ type Preset struct { // NewPreset builds the screen. links are the buttons to the other screens. func NewPreset(host Host, links ...fyne.CanvasObject) *Preset { - p := &Preset{runner: newRunner(host.Later), host: host, tips: parts.NewTips()} + p := &Preset{runner: newRunner(host.Later, host.Remembered, host.MachineMemory), host: host, tips: parts.NewTips()} p.runner.offer.through(host) p.runner.settle = watchedSettle(host, p.runner, p.settle) // No readdress here, and that is the boundary of this screen rather than an diff --git a/internal/gui/window/recipe.go b/internal/gui/window/recipe.go index 86f0c18c..94ac0ea4 100644 --- a/internal/gui/window/recipe.go +++ b/internal/gui/window/recipe.go @@ -158,7 +158,7 @@ type content struct { // anybody does a press of "Add a batch" to find out what the screen is. One is // also what the single batch screen shows, so the two read as one tool. func NewRecipe(host Host, links ...fyne.CanvasObject) *Recipe { - r := &Recipe{runner: newRunner(host.Later), host: host, tips: parts.NewTips()} + r := &Recipe{runner: newRunner(host.Later, host.Remembered, host.MachineMemory), host: host, tips: parts.NewTips()} r.runner.settle = watchedSettle(host, r.runner, r.settle) r.runner.offer.through(host) // A refusal about a size belongs on the box the switch is showing. diff --git a/internal/gui/window/remembered.go b/internal/gui/window/remembered.go index c6abff43..eed8d486 100644 --- a/internal/gui/window/remembered.go +++ b/internal/gui/window/remembered.go @@ -12,8 +12,9 @@ import "fyne.io/fyne/v2" // // And since 2026-09-29 one thing it is told: the language chosen on the // Preferences screen. That is not last week's state coming back uninvited, it -// is a choice somebody made in order to keep it, and it is the one value here -// that nothing writes but a person. +// is a choice somebody made in order to keep it, and nothing writes it but a +// person. Since 2026-10-27 the memory setting beside it is the second such +// value. // // It is an interface on the Host rather than a call to the toolkit's global // preferences, for the reason every other seam here exists: the screens build @@ -52,6 +53,15 @@ type Remembered interface { Language() string RememberLanguage(tag string) + // Memory is the memory setting somebody chose on the Preferences screen, + // as core.Memory writes it - "8gb", "off" - or empty when nobody has, + // which means auto (docs/STABILITY-REVIEW-2026-10-26.md section 10.8). + // Like the language it is a choice somebody made in order to keep it, so + // it is written after Forget too, and RememberMemory with an empty text + // removes the value rather than writing an empty one. + Memory() string + RememberMemory(setting string) + // Forget removes everything above, on the press of the Forget button and // at no other time - untouchable rule 7. For the rest of this run the // folder and the size are not written down again either, or closing the diff --git a/internal/gui/window/run.go b/internal/gui/window/run.go index 206bedfd..f91b1fee 100644 --- a/internal/gui/window/run.go +++ b/internal/gui/window/run.go @@ -77,6 +77,14 @@ type settler func() ([]engine.Target, engine.Options, error) // run was cut short. type runner struct { settle settler + // kept is where the memory setting chosen on the Preferences screen is + // read from, at the start of every preview and run - so a setting changed + // there applies from the next run and never to one already going. + kept func() Remembered + // machine is the memory this machine gives the program, asked of the same + // host the Preferences screen asks, so the run works to the figure that + // screen says Automatic comes to. + machine func() (int64, error) previewBtn *parts.Button generateBtn *parts.Button @@ -338,8 +346,8 @@ func lineFrom(r *runner, targets []engine.Target, opt engine.Options, err error) showOn(r.status, r.line.said(summarise(targets), opt.OutDir)) } -func newRunner(wait later) *runner { - r := &runner{fields: parts.NewFields(), line: &runLine{}, sections: newSections()} +func newRunner(wait later, kept func() Remembered, machine func() (int64, error)) *runner { + r := &runner{fields: parts.NewFields(), line: &runLine{}, sections: newSections(), kept: kept, machine: machine} // Wired once, here, so that a field added later is covered without anybody // remembering to wire it. See Fields.WhenTypedIn and recheck. r.fields.WhenTypedIn(r.recheck) @@ -436,6 +444,7 @@ func (r *runner) onPreview() { return } opt.DryRun = true + opt.Memory, opt.MachineMemory = memoryOf(r.kept()), r.machine // Occupied, and stoppable. Both of those changed on 2026-08-26. // @@ -531,7 +540,7 @@ func (r *runner) previewFinished(res *engine.Result, planned []engine.PlannedFil r.line.measured(opt.OutDir, room.free) } said := r.line.said(exactly(planned), opt.OutDir) + text.AndNothingWrittenYet() - r.say(append([]string{said}, manifestReachNote(res.Record)...)...) + r.say(append(append([]string{said}, manifestReachNote(res.Record)...), runNotesOf(res)...)...) } // formatsOf is what kinds of file the run would produce, each named once. @@ -595,6 +604,7 @@ func (r *runner) onGenerate() { } func (r *runner) startRun(targets []engine.Target, opt engine.Options) { + opt.Memory, opt.MachineMemory = memoryOf(r.kept()), r.machine ctx, cancel := context.WithCancel(context.Background()) done := make(chan struct{}) r.settled = done @@ -725,6 +735,7 @@ func (r *runner) runFinished(res *engine.Result, runErr, saveErr error, room dis // sentence both - the second carries the path again (review on #140). said = append(said, text.InstructionsNotSaved(core.Shown(saved.Missed.Path), core.ShownText(text.Refusal(saved.Missed.Err, "")))) } + said = append(said, runNotesOf(res)...) r.say(append(said, notesOf(res)...)...) r.toneOfOutcome(res, runErr) r.offer.theFolder(res) @@ -798,8 +809,8 @@ func (r *runner) holdBeforeFinishing() { // Settled waits for work in flight to finish, without stopping it. // // A seam for the guards, and named as one rather than dressed up - the same -// kind of thing as Options.AvailableBytes and Options.MaxPlanBytes in the -// engine, which exist so a test can describe a small disk or a small ceiling +// kind of thing as Options.AvailableBytes and Options.MachineMemory in the +// engine, which exist so a test can describe a small disk or a small machine // without owning one. // // It is here because of what changed on 2026-08-26. Until then a preview could diff --git a/internal/gui/window/runoutcome.go b/internal/gui/window/runoutcome.go index 259367c9..f4e8edd2 100644 --- a/internal/gui/window/runoutcome.go +++ b/internal/gui/window/runoutcome.go @@ -24,6 +24,20 @@ func outcomeText(res *engine.Result, runErr error) string { return text.Written(written) } +// runNotesOf is what a run says about itself rather than about its files - the +// memory it ran in - in the window's language. Said ahead of the notes about +// files, in the order the command line says them. +func runNotesOf(res *engine.Result) []string { + if res == nil { + return nil + } + out := make([]string, 0, len(res.RunNotes)) + for _, s := range res.RunNotes { + out = append(out, text.Sentence(s)) + } + return out +} + // notesOf is what a run says about its files, in the window's language. A // note the run did not remember as a sentence - there is none today - is said // as the manifest holds it.