From c952881c52fd651a6c693b472148d52721786c0f Mon Sep 17 00:00:00 2001 From: DonislawDev Date: Mon, 7 Sep 2026 06:59:22 +0200 Subject: [PATCH] fix: a stopped run says so, and says what survived Ctrl+C and a CI timeout both printed "tfg: context canceled" - six characters of Go runtime vocabulary handed to somebody who had just told the tool to stop and now wanted to know whether their directory was safe to reuse. The window has said the useful thing since it had a progress bar. The command line was the surface staying quiet. tfg: stopped before it finished. 897 files written, and the manifest describes exactly those. Reproduced against a real signal in a Linux container, because a signal cannot be delivered to this process from the shell on this machine - which is why O175 was written from the code on 2026-09-02 and left open. SIGINT into a run of 3000: exit 130, "897 files written", 897 on disk. SIGTERM: exit 143, "755 files written", 755 on disk. The count matches to the file. Two sentences rather than three, and that is a decision. The report asked for Ctrl+C and SIGTERM to read differently, since that is why their exit codes differ. Both arrive here as context.Canceled - the signal is known only in cmd/tfg/main.go, deliberately, because signal.NotifyContext does not say which one arrived. Separating them in the words would mean plumbing the signal into cli.Run, whose signature the whole guard suite is written against. The exit code already carries it, and that is the channel a script reads. A deadline gets its own sentence, because the error itself distinguishes it and that costs nothing. The guard reaches the sentence and not the count, and the code says so. The command line plans before it runs and planning honours the context, so a run started with a finished context returns from PlanContext and never reaches the count. Landing between the two needs a clock, and a guard on a clock goes red on a busy machine rather than on a defect. Worth recording: the first version had a third guard, for a run that never began claiming no count. It was green and could not be reddened, because that path does not call whatSurvived at all. The mutation found it - NOT CAUGHT - and the guard was removed rather than kept for comfort. Two mutations, both caught. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 18 ++++ internal/cli/errors.go | 27 ++++++ internal/cli/generate.go | 50 ++++++++++- internal/guard/stoppedsentence_test.go | 110 +++++++++++++++++++++++++ 4 files changed, 204 insertions(+), 1 deletion(-) create mode 100644 internal/guard/stoppedsentence_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 79069de..53c1533 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -299,6 +299,24 @@ because it turns other people's test suites red. ### Fixed +- **A stopped run says what happened and what survived, instead of `context + canceled`.** Pressing Ctrl+C, or a CI job running out of time, printed six + characters of Go vocabulary and left you to work out whether the directory was + safe to reuse. It now reads: + + ``` + tfg: stopped before it finished. 897 files written, and the manifest describes + exactly those. + manifest: /out/manifest.json + ``` + + So `tfg cleanup` can take exactly those away again. The window has said this + since it had a progress bar - only the command line was silent about it. + + Exit codes are unchanged: `130` for a cancel, `143` for a signal that says + time is up. A run whose deadline ran out says that rather than that it was + cancelled. + - **A refusal about a size now names the setting you actually wrote.** A target using `size-range` was refused at `targets[1].size`, a key that recipe does not have, so `validate --json` sent a script - and the window sent a person - diff --git a/internal/cli/errors.go b/internal/cli/errors.go index 71fdd63..6882c80 100644 --- a/internal/cli/errors.go +++ b/internal/cli/errors.go @@ -37,6 +37,33 @@ func describeError(err error) string { return "" } + // The two ways a run is stopped from outside get our own sentence, because + // theirs is "context canceled" - six characters of Go runtime vocabulary + // printed to somebody who asked the tool to stop and now wants to know what + // is on their disk. Written up as O175 on 2026-09-02 and left open, with + // the note that the four part shape of a refusal (D6) does not apply here: + // a stop is not a fault to correct, it is the answer to what was asked for. + // + // ONE SENTENCE FOR CANCELLATION RATHER THAN TWO, and that is a decision + // rather than an omission. Ctrl+C and SIGTERM both arrive here as + // context.Canceled - the signal itself is known only in cmd/tfg/main.go, + // deliberately, because signal.NotifyContext does not say which signal + // arrived and the exit code table tells them apart. Telling them apart in + // the WORDS as well would mean plumbing the signal into cli.Run, whose + // signature the whole guard suite is written against. The exit code already + // carries the distinction - 130 against 143 - and that is the channel a + // script reads. A person who pressed Ctrl+C does not need to be told so. + // + // A deadline is different and costs nothing, because the error itself says + // so. Nothing on the command line sets one today, so this is reachable only + // through a caller that does. + if errors.Is(err, context.Canceled) { + return "stopped before it finished." + } + if errors.Is(err, context.DeadlineExceeded) { + return "stopped before it finished, because the time allowed for it ran out." + } + var errno syscall.Errno if !errors.As(err, &errno) { return err.Error() diff --git a/internal/cli/generate.go b/internal/cli/generate.go index c6c65de..4e516b4 100644 --- a/internal/cli/generate.go +++ b/internal/cli/generate.go @@ -419,7 +419,7 @@ func produce(ctx context.Context, targets []engine.Target, opt engine.Options, g } if runErr != nil { - fmt.Fprintf(errOut, "tfg: %s\n", describeError(runErr)) + fmt.Fprintf(errOut, "tfg: %s%s\n", describeError(runErr), whatSurvived(runErr, res)) // A run that was refused before it wrote anything gets no manifest. // Writing one would replace the record of whatever was already in the // directory, and that record is the only thing cleanup can work from. @@ -459,6 +459,54 @@ func produce(ctx context.Context, targets []engine.Target, opt engine.Options, g return ExitOK } +// whatSurvived says what a stopped run left behind, for the sentence above. +// +// The window has said this since it had a progress bar - "Stopped after N +// files. The manifest describes exactly those." The command line said "context +// canceled" and left the reader to work out whether the directory was safe to +// reuse. Same run, same facts, and only one surface was saying them. +// +// Only for a stop, and only for a run that got past its preflight. A run +// refused before it wrote anything has nothing to describe, and a run that +// failed for its own reason already says what went wrong in its own words - +// adding a count to either would be answering a question nobody asked. +// +// The claim it makes is the one the manifest keeps: every file that reached the +// disk has an entry, hole allowed. That is the row of the regression surface +// about a stopped run naming what it produced, and it is what makes "tfg +// cleanup" able to take them away again. +// +// PROVEN BY RUNNING IT, NOT BY A GUARD, and that is worth knowing before +// trusting it. Measured on 2026-09-06 in a Linux container against a real +// signal, because a signal cannot be delivered to this process from the shell +// on the machine this was written on: +// +// SIGINT into 3000 files exit 130 "897 files written" 897 on disk +// SIGTERM into 3000 files exit 143 "755 files written" 755 on disk +// +// A guard reaches the sentence but not the count. The command line plans before +// it runs, and planning honours the context, so a run started with a finished +// context returns from PlanContext and never arrives here - res is nil and the +// count is never built. Landing between the two needs a cancel timed to arrive +// after planning and before the last file, which is a clock, and a guard built +// on a clock goes red on a busy machine rather than on a defect. +// +// So !res.Started is not reddenable from this surface today. It stays because +// the state it refuses is reachable in the engine - Run sets Manifest at +// construction and Started only after preflight, so a stop returned between +// those two would otherwise print "0 files written, and the manifest describes +// exactly those" about a run that wrote nothing and saved no manifest. That is +// an invented fact rather than a missing one, which is the half of untouchable +// rule 5 that costs trust. +func whatSurvived(runErr error, res *engine.Result) string { + stopped := errors.Is(runErr, context.Canceled) || errors.Is(runErr, context.DeadlineExceeded) + if !stopped || res == nil || !res.Started || res.Manifest == nil { + return "" + } + return fmt.Sprintf(" %s written, and the manifest describes exactly those.", + core.Count(len(res.Manifest.Files), "file", "files")) +} + // defaultManifestName is where the manifest lands when nothing says otherwise. // echoBoundaries spells out a boundary set, because a boundary set exists to diff --git a/internal/guard/stoppedsentence_test.go b/internal/guard/stoppedsentence_test.go new file mode 100644 index 0000000..7a3dc79 --- /dev/null +++ b/internal/guard/stoppedsentence_test.go @@ -0,0 +1,110 @@ +package guard + +import ( + "bytes" + "context" + "strings" + "testing" + "time" + + "github.com/donislawdev/TestingFilesGenerator/internal/cli" + _ "github.com/donislawdev/TestingFilesGenerator/internal/format/all" +) + +// A stopped run says so in our words, not Go's. +// +// The exit codes were already right and are not what this is about: 130 for a +// cancel and 143 for a signal that says time is up, told apart deliberately +// because "every stop used to be reported as Ctrl+C, so a CI job that timed out +// looked like somebody had cancelled it". The SENTENCE never got the same +// treatment. Both endings printed "tfg: context canceled" - six characters of +// Go runtime vocabulary handed to somebody who asked the tool to stop and now +// wants to know what is on their disk. +// +// Written up as O175 on 2026-09-02 from the code rather than from a run, +// because a signal could not be delivered to the process from the shell on this +// machine. This guard needs no signal: cli.Run takes the context, so handing it +// a finished one reaches the same path. +// +// The four part shape of a refusal (D6) does not apply. A stop is not a fault +// to correct, it is the answer to what was asked for. +func TestAStoppedRunSaysSoInOurOwnWords(t *testing.T) { + body := "version: 1\ntargets:\n - id: a\n format: txt\n size: 1kb\n count: 3\n" + + for _, c := range []struct { + name string + args func(recipe, out string) []string + }{ + {"generate", func(r, o string) []string { return []string{"generate", r, "--out", o} }}, + {"validate", func(r, _ string) []string { return []string{"validate", r} }}, + } { + t.Run(c.name, func(t *testing.T) { + dir := t.TempDir() + args := c.args(writeRecipe(t, dir, body), t.TempDir()) + + // The live half. Without it the stopped half below would pass for a + // command that was refused for some other reason entirely. + var out, errOut bytes.Buffer + if code := cli.Run(context.Background(), args, &out, &errOut); code != cli.ExitOK { + t.Fatalf("with a live context this ended with %d, so the stopped half proves nothing\nstderr: %s", + code, errOut.String()) + } + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + out.Reset() + errOut.Reset() + + if code := cli.Run(ctx, args, &out, &errOut); code != cli.ExitInterrupted { + t.Fatalf("with a finished context this ended with %d, expected %d\nstderr: %s", + code, cli.ExitInterrupted, errOut.String()) + } + + said := errOut.String() + if strings.Contains(said, "context canceled") { + t.Errorf("a stopped run still prints Go's own words:\n%s\n"+ + "Somebody reading this wants to know what happened to their directory, and "+ + "\"context canceled\" is vocabulary from the language this is written in.", said) + } + if !strings.Contains(said, "stopped") { + t.Errorf("the sentence does not say the run was stopped:\n%s", said) + } + }) + } +} + +// A deadline is a different ending and says so. +// +// Free, because the error itself distinguishes it - unlike Ctrl+C and SIGTERM, +// which both arrive as context.Canceled and are told apart by the exit code +// instead. Nothing on the command line sets a deadline today, so this is +// reachable only through a caller that does, which the window will be. It is +// here before it is needed rather than after somebody has reported the tool +// crashing. +func TestARunThatRanOutOfTimeSaysThatRatherThanThatItWasCancelled(t *testing.T) { + dir := t.TempDir() + args := []string{"validate", writeRecipe(t, dir, + "version: 1\ntargets:\n - id: a\n format: txt\n size: 1kb\n")} + + ctx, cancel := context.WithTimeout(context.Background(), time.Nanosecond) + defer cancel() + // Waited for rather than assumed. A deadline that has not passed yet would + // let the run succeed and this would prove nothing. + <-ctx.Done() + + var out, errOut bytes.Buffer + code := cli.Run(ctx, args, &out, &errOut) + if code != cli.ExitTerminated { + t.Fatalf("a run whose deadline had passed ended with %d, expected %d\nstderr: %s", + code, cli.ExitTerminated, errOut.String()) + } + + said := errOut.String() + if strings.Contains(said, "context deadline exceeded") { + t.Errorf("a run that ran out of time prints Go's own words:\n%s", said) + } + if !strings.Contains(said, "time") { + t.Errorf("the sentence does not say that time was what ran out, so it reads the same "+ + "as somebody pressing Ctrl+C:\n%s", said) + } +}