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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 -
Expand Down
27 changes: 27 additions & 0 deletions internal/cli/errors.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
50 changes: 49 additions & 1 deletion internal/cli/generate.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down
110 changes: 110 additions & 0 deletions internal/guard/stoppedsentence_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
Loading