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) + } +}