Skip to content

Commit 296a84e

Browse files
donislawdevclaude
andcommitted
guard: make the stopped run hole certain instead of raced for
TestARunStoppedPartWayNamesEveryFileThatFinished needs a run where one writer is still owed bytes while its neighbours have been renamed into place. It bought that state with size - a first file sixteen times the size of the rest - and that made it flaky, roughly one run in four inside a full suite. Size buys margin in work, and which writer finishes first is decided by which one gets a processor. Measured with tools/probes/stoprace, fifty runs per condition: idle machine 0 failures in 50, 165 ms of unused margin heavy disk writing beside it 0 failures in 25 CPU starved, 24 busy loops 14 failures in 25 So the disk was never involved, which is what the observation first recorded. Under starvation the time to the first finished file goes from 11 ms to 5.8 s, and sixteen times the work stops meaning anything. A full suite runs packages beside each other and builds binaries in sub processes, so a starved machine is the normal condition there. The file at index zero now carries a generator that writes nothing and returns only once the run is cancelled, so it cannot finish whoever gets a processor. Descriptor is a value and every planned file carries its own copy, so this is installed after planning on one PlannedFile - the registry is untouched, the other thirty one files write real bytes, and every step of the engine below Plan is the one that ships. The guard also asserts it has at least two writers, because with one the held open file would wait for a cancellation nobody can send. Measured after the change: 25 of 25 green under the same starvation, the mutation that restores the prefix behaviour still reddens it, and the guard writes 3.5 MiB where it used to write 94 MiB. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent 9c8e468 commit 296a84e

2 files changed

Lines changed: 117 additions & 15 deletions

File tree

‎internal/guard/heldopen_test.go‎

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
package guard
2+
3+
import (
4+
"context"
5+
"errors"
6+
"io"
7+
"time"
8+
9+
"github.com/donislawdev/TestingFilesGenerator/internal/format"
10+
)
11+
12+
// heldOpenGenerator is a file that never finishes.
13+
//
14+
// It exists for one guard - TestARunStoppedPartWayNamesEveryFileThatFinished -
15+
// which needs a run where one writer is still owed bytes while its neighbours
16+
// have already been renamed into place. That state used to be bought with
17+
// size, a first file sixteen times the size of the rest, and that was a race:
18+
// which writer finishes first is decided by which one gets a processor, not by
19+
// how much work it was given. Measured on 2026-09-08, a starved machine failed
20+
// it fourteen times in twenty five. The note above that guard carries the
21+
// numbers.
22+
//
23+
// Writing nothing and waiting removes the race rather than widening it. No
24+
// amount of scheduling pressure can make this file finish, so the hole is a
25+
// property of the plan instead of an outcome of a contest.
26+
//
27+
// This is put in place AFTER planning, on one PlannedFile, so it never enters
28+
// the registry and no other file sees it. Every step of the engine below
29+
// planning is the one that ships.
30+
type heldOpenGenerator struct{}
31+
32+
// heldOpenDeadline is a safety net and not the mechanism.
33+
//
34+
// The cancellation this generator waits for arrives as soon as any other
35+
// writer finishes a file, which on an idle machine is eleven milliseconds and
36+
// on the most starved machine measured was under fifteen seconds. If it never
37+
// arrives at all the run is deadlocked, and a deadlock would hang the whole
38+
// package until the suite timeout kills it with no word about which test was
39+
// stuck. Failing here instead says what happened, on the guard that caused it.
40+
const heldOpenDeadline = 90 * time.Second
41+
42+
// Plan is never called. This generator replaces the real one after planning,
43+
// so the file it belongs to already carries the plan the txt format made.
44+
// Refusing rather than returning a zero plan, because a zero plan would let a
45+
// future caller get a silently empty file out of this.
46+
func (*heldOpenGenerator) Plan(format.Request) (format.Plan, error) {
47+
return format.Plan{}, errors.New("guard: this generator is installed after planning and has no plan of its own")
48+
}
49+
50+
// Write emits nothing and returns when the run is cancelled.
51+
//
52+
// Returning the context error is what a generator interrupted half way does,
53+
// so the engine sees the same thing it would see from any format that was cut
54+
// off - the temporary file is removed, no entry claims the file, and the index
55+
// stays empty.
56+
func (*heldOpenGenerator) Write(ctx context.Context, _ io.Writer, _ format.Plan) error {
57+
select {
58+
case <-ctx.Done():
59+
return ctx.Err()
60+
case <-time.After(heldOpenDeadline):
61+
return errors.New("guard: the run was never cancelled, so no other writer ever " +
62+
"finished a file and the hole this guard is about could not occur")
63+
}
64+
}

‎internal/guard/safety_test.go‎

Lines changed: 53 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -187,17 +187,51 @@ func TestAnInterruptedRunLeavesNoPartialFileAndStillWritesAManifest(t *testing.T
187187
// cut off half way, what the run left behind was a prefix after all, and the
188188
// mutation that puts the prefix behaviour back could not redden anything.
189189
// A guard that names the defect and cannot meet it is the shape this project
190-
// has recorded twice. So the plan below is built to guarantee the hole: the
191-
// FIRST file is sixteen times the size of the rest, so a later one always
192-
// finishes first, the cancellation always lands while file one is still being
193-
// written, and index one is always missing from what survives.
190+
// has recorded twice.
191+
//
192+
// The second version bought the hole with SIZE: the first file was sixteen
193+
// times the size of the rest, so a smaller one was expected to always finish
194+
// first. That version was flaky, roughly one run in four inside a full suite,
195+
// and the fix is not a bigger first file. Measured on 2026-09-08 with
196+
// tools/probes/stoprace, fifty runs per condition:
197+
//
198+
// idle machine 0 failures in 50, and the big file had 165 ms
199+
// of margin it never came close to using
200+
// heavy disk writing beside it 0 failures in 25
201+
// CPU starved, 24 busy loops 14 failures in 25
202+
//
203+
// So the flakiness was never about how fast the disk is. Size buys margin in
204+
// WORK, and what decides which writer finishes first is which writer gets a
205+
// processor. Starve the machine and the time to the first finished file goes
206+
// from 11 ms to 5.8 s, three orders of magnitude, at which point sixteen times
207+
// the work means nothing at all. A full suite runs packages beside each other
208+
// and builds binaries in sub processes, so a starved machine is the normal
209+
// condition rather than the exotic one.
210+
//
211+
// This version does not race. The file at index zero is given a generator that
212+
// writes nothing and returns only once the run is cancelled, so it can never
213+
// finish no matter who gets a processor. Descriptor is a value and every
214+
// planned file carries its own copy, so this replaces the generator for that
215+
// one file and leaves the other thirty one writing real bytes through the real
216+
// registry. Every step of the engine below Plan is the one that ships.
194217
func TestARunStoppedPartWayNamesEveryFileThatFinished(t *testing.T) {
195218
dir := t.TempDir()
196219

197220
// Raised so several writers exist wherever this runs, because with one
198221
// writer a stopped run leaves a prefix and the hole this guard is about
199-
// cannot occur.
222+
// cannot occur. It is also what keeps the blocked file below from being a
223+
// deadlock: somebody other than the blocked writer has to finish a file,
224+
// or the cancellation this guard waits for is never sent.
225+
//
226+
// Asserted rather than assumed. The pool is min(GOMAXPROCS, files), so a
227+
// build that ever answered one here would hang instead of failing, and
228+
// this guard has already been bitten once by a condition it took for
229+
// granted.
200230
defer runtime.GOMAXPROCS(runtime.GOMAXPROCS(8))
231+
if n := runtime.GOMAXPROCS(0); n < 2 {
232+
t.Fatalf("this guard needs at least two writers and GOMAXPROCS is %d, so the "+
233+
"file held open below would wait for a cancellation nobody can send", n)
234+
}
201235

202236
ctx, cancel := context.WithCancel(context.Background())
203237
defer cancel()
@@ -214,19 +248,23 @@ func TestARunStoppedPartWayNamesEveryFileThatFinished(t *testing.T) {
214248
}
215249
},
216250
}
217-
// One big file and thirty one small ones, and the order is the whole
218-
// point. Every writer starts at once, one of the small ones finishes long
219-
// before the big one can, and the cancellation that follows cuts the big
220-
// one off - leaving a finished file at a HIGHER index than one that never
221-
// finished, which is the only shape a sequential loop could not produce.
222-
sizes := append([]int64{32 << 20}, engine.Uniform(31, 2<<20)...)
251+
// Thirty two files of one size. The asymmetry that used to live here was
252+
// in the sizes and it is now in the generator, which is the whole of the
253+
// fix - see the note above the function.
223254
planned, err := engine.Plan([]engine.Target{{
224-
ID: "files", Format: "txt", Sizes: sizes,
255+
ID: "files", Format: "txt", Sizes: engine.Uniform(32, 512<<10),
225256
}}, opt)
226257
if err != nil {
227258
t.Fatalf("planning: %v", err)
228259
}
229260

261+
// Index zero is held open for the length of the run. Its writer reaches
262+
// the generator, writes nothing and waits, so a file at a HIGHER index is
263+
// renamed into place while this one is still owed - which is the only
264+
// shape a sequential loop could not produce, and the shape this guard
265+
// exists to be about.
266+
planned[0].Desc.Generator = &heldOpenGenerator{}
267+
230268
res, runErr := engine.Run(ctx, planned, opt)
231269
if runErr == nil {
232270
t.Fatal("the run was cancelled from inside itself and reported success")
@@ -273,9 +311,9 @@ func TestARunStoppedPartWayNamesEveryFileThatFinished(t *testing.T) {
273311
"was never stopped part way", len(planned))
274312
}
275313
if onDisk[planned[0].Name] {
276-
t.Fatalf("%s is the biggest file in the run and it finished anyway, so nothing "+
277-
"here was cut off half way and the survivors are a prefix - which is not the "+
278-
"case this guard is about", planned[0].Name)
314+
t.Fatalf("%s was held open for the whole run and it is on the disk anyway, so "+
315+
"the survivors are a prefix and nothing here was cut off half way - which "+
316+
"is not the case this guard is about", planned[0].Name)
279317
}
280318

281319
for name := range onDisk {

0 commit comments

Comments
 (0)