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
15 changes: 15 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,21 @@ because it turns other people's test suites red.

### Fixed

- **Ctrl+C stops a run that draws its sizes from a range.** With
`--size-range`, every file was tried at its drawn size before anything else,
and that part did not look at Ctrl+C - three thousand JPG files went on for
about fifteen seconds after it. It now stops at once, `--dry-run` and
`validate` included.
- **Ctrl+C stops a large DOCX, PPTX or XLSX at once.** The padding of a large
Office file is generated twice, once to sum it and once to write it, and the
first pass did not look at Ctrl+C - about half a second for a 2 GB file. It
now stops in the middle of it.
- **A file whose name differs from a planned one only in case is never
written over.** On Windows and macOS `FILES_0001.TXT` and `files_0001.txt`
are one file, and `--dry-run` said nothing was in the way while the run
wrote the other files before it stopped with code 8. Such a run is now
refused with code 5 before anything is written, naming the file that is
there. Where names keep case apart, both are written as before.
- **A size with a decimal point is read to the byte.** A fraction finer than
the arithmetic behind it was rounded without a word, so
`--size 255.99999999999999B` made a file of 256 B and `9007199254740993.0B`
Expand Down
25 changes: 17 additions & 8 deletions internal/engine/drawsizes.go
Original file line number Diff line number Diff line change
Expand Up @@ -41,11 +41,11 @@ import (
// SmallestAccepted is called from guards and from the window, where that wrapper
// is not in hand. Merging them would mean handing a planner in, which buys less
// than it costs.
func firstWritable(desc format.Descriptor, r format.Request, from, limit int64) (int64, error) {
func firstWritable(ctx context.Context, desc format.Descriptor, r format.Request, from, limit int64) (int64, error) {
var first error
for at := from; at <= limit; {
r.Bytes = at
_, err := planWithoutCrashing(context.Background(), desc, r)
_, err := planWithoutCrashing(ctx, desc, r)
if err == nil {
return at, nil
}
Expand Down Expand Up @@ -100,7 +100,15 @@ func firstWritable(desc format.Descriptor, r format.Request, from, limit int64)
// Bytes do not move for any run that worked before. A run that succeeds today
// has every drawn size writable, or it would have failed, and snapping a
// writable size returns it unchanged.
func drawSizes(t *Target, desc format.Descriptor, targetSeed uint64) error {
//
// It takes the run's context because drawing IS planning: every probe plans
// the file, and a jpg plans by encoding its picture. Until 2026-10-27 each
// probe got a context of its own, so a stop waited for the draw to finish -
// 13.7 to 15.8 s after Ctrl+Break for three thousand jpg files, where a fixed
// size stopped in 0.01 s (docs/STABILITY-REVIEW-2026-10-26.md, R05).
// format.PlanWith asks the context before anything else, and a stop is not a
// refusal about the size, so firstWritable hands it straight back.
func drawSizes(ctx context.Context, t *Target, desc format.Descriptor, targetSeed uint64) error {
first := format.Request{
Contains: t.Contains,
Seed: core.FileSeed(targetSeed, 0),
Expand All @@ -120,7 +128,7 @@ func drawSizes(t *Target, desc format.Descriptor, targetSeed uint64) error {
// UTF-16, the band above a PNG's encoded picture - is a different thing:
// nobody can be expected to enumerate those, and snapping inside the range
// is the answer.
floor, err := firstWritable(desc, first, 0, t.Range.Max)
floor, err := firstWritable(ctx, desc, first, 0, t.Range.Max)
if err != nil {
// The floor is above the whole range, so nothing in it is writable.
return err
Expand All @@ -129,7 +137,7 @@ func drawSizes(t *Target, desc format.Descriptor, targetSeed uint64) error {
// Asked again at the low end so the format words its own refusal, with
// the number the person actually wrote.
first.Bytes = t.Range.Min
if _, err := planWithoutCrashing(context.Background(), desc, first); err != nil {
if _, err := planWithoutCrashing(ctx, desc, first); err != nil {
return err
}
}
Expand All @@ -154,13 +162,14 @@ func drawSizes(t *Target, desc format.Descriptor, targetSeed uint64) error {
Properties: t.Properties,
}

got, err := firstWritable(desc, req, want, t.Range.Max)
got, err := firstWritable(ctx, desc, req, want, t.Range.Max)
if err != nil && want > t.Range.Min {
// Nothing writable from the draw upwards. The bottom of the range
// can still hold something - a draw landing on the last odd number
// of a range has nowhere above it and plenty below - so the range is
// only empty once THAT fails too.
got, err = firstWritable(desc, req, t.Range.Min, t.Range.Max)
// only empty once THAT fails too. After a stop this second try
// hands the stop back at its first probe.
got, err = firstWritable(ctx, desc, req, t.Range.Min, t.Range.Max)
}
if err != nil {
return err
Expand Down
6 changes: 3 additions & 3 deletions internal/engine/engine.go
Original file line number Diff line number Diff line change
Expand Up @@ -251,7 +251,7 @@ func (f PlannedFile) Damaged() bool {
// askable at all", and the loop it was taken out of answers "what files does it
// come to". seen carries across targets because a duplicate id is a fact about
// the run rather than about one entry.
func settleTarget(t *Target, opt Options, seen map[string]bool) (format.Descriptor, error) {
func settleTarget(ctx context.Context, t *Target, opt Options, seen map[string]bool) (format.Descriptor, error) {
if t.ID == "" {
return format.Descriptor{}, &RecipeError{Setting: SettingID,
Detail: core.Says("engine.NoID", "a target has no id"),
Expand Down Expand Up @@ -297,7 +297,7 @@ func settleTarget(t *Target, opt Options, seen map[string]bool) (format.Descript
// for every registered format. It was removed rather than kept as defence
// nobody can verify.
if t.Range.Used {
if err := drawSizes(t, desc, core.TargetSeed(opt.Seed, t.ID)); err != nil {
if err := drawSizes(ctx, t, desc, core.TargetSeed(opt.Seed, t.ID)); err != nil {
return format.Descriptor{}, err
}
}
Expand Down Expand Up @@ -427,7 +427,7 @@ func PlanContext(ctx context.Context, targets []Target, opt Options) ([]PlannedF
// Everything refused from here down is refused about one target, so it
// carries which one. See atTarget for what that is worth and what it
// deliberately leaves alone.
desc, err := settleTarget(t, opt, seen)
desc, err := settleTarget(ctx, t, opt, seen)
if err != nil {
return nil, atTarget(i+1, t, err)
}
Expand Down
11 changes: 11 additions & 0 deletions internal/engine/errors.go
Original file line number Diff line number Diff line change
Expand Up @@ -259,6 +259,12 @@ type CollisionError struct {
// Instructions is set when what is in the way is the instructions of an
// earlier run, which describe that run's files.
Instructions bool
// OnDisk is the name in the directory when it is spelt differently from
// the one this run would write - FILES_0001.TXT in the way of
// files_0001.txt on a system that ignores case. Empty when the two are the
// same spelling. Without it the refusal named a file nobody could find in
// the listing.
OnDisk string
}

func (e *CollisionError) Error() string { return e.Said().String() }
Expand All @@ -275,6 +281,11 @@ func (e *CollisionError) Said() core.Said {
"%s already exists and this run will not write over it. It is the only record of what an earlier run wrote, so replacing it would leave those files with nothing to remove them by. Generate into an empty directory, or move the old manifest aside",
core.A("Path", core.Shown(e.Path)))
}
if e.OnDisk != "" {
return core.Says("engine.CollisionOtherCase",
"%s already exists as %s, and this system does not tell the two names apart, so this run would write over it. Generate into an empty directory, or remove the file first",
core.A("Path", core.Shown(e.Path)), core.A("OnDisk", core.Shown(e.OnDisk)))
}
return core.Says("engine.Collision",
"%s already exists and this run will not write over it. Generate into an empty directory, or remove the file first",
core.A("Path", core.Shown(e.Path)))
Expand Down
108 changes: 86 additions & 22 deletions internal/engine/preflight.go
Original file line number Diff line number Diff line change
Expand Up @@ -150,16 +150,33 @@ func preflight(ctx context.Context, files []PlannedFile, opt Options, record Rec
if err := ctx.Err(); err != nil {
return err
}
if path := filepath.Join(opt.OutDir, f.Name); isTaken(taken, path, f.Name) {
return &CollisionError{Path: path}
path := filepath.Join(opt.OutDir, f.Name)
if hit, onDisk := taken.holds(path, f.Name); hit {
return &CollisionError{Path: path, OnDisk: onDisk}
}
if path := tempPathFor(opt.OutDir, f.Name); isTaken(taken, path, tempNameFor(f.Name)) {
return &CollisionError{Path: path}
temp := tempPathFor(opt.OutDir, f.Name)
if hit, onDisk := taken.holds(temp, tempNameFor(f.Name)); hit {
return &CollisionError{Path: temp, OnDisk: onDisk}
}
}
return nil
}

// listing is what the output directory holds, kept two ways: every name as it
// is spelt, and every name folded the way a filesystem that ignores case
// compares them, pointing back at each spelling on the disk with that fold.
//
// Each, rather than the first. FoldName joins pairs a filesystem keeps apart:
// measured on 2026-10-27 on NTFS, STRASSE.txt and Stra\u00dfe.txt are two files
// side by side and both fold to strasse.txt, and a lookup of stra\u00dfe.txt finds
// the second. Keeping one spelling named STRASSE.txt - the file a refusal
// would then tell somebody to remove, and the wrong one. Found by CodeRabbit
// on #207 (docs/REVIEW-207-2026-10-27.md).
type listing struct {
exact map[string]struct{}
folded map[string][]string
}

// namesIn is every name the output directory holds, or nil when it could not be
// listed at all.
//
Expand All @@ -172,36 +189,83 @@ func preflight(ctx context.Context, files []PlannedFile, opt Options, record Rec
// A directory that does not exist yet is a different answer and a correct one:
// it holds no names, nothing can collide, and that is the ordinary case for a
// fresh run.
func namesIn(dir string) map[string]struct{} {
//
// The folded half costs one core.FoldName a name, measured at 254 ns on
// 2026-09-03 (docs/STABILITY-REVIEW-2026-09-02.md section 3.2) - about 2.5 ms
// for a directory of ten thousand, against the 1.9 s that asking the system
// about every planned name cost before the listing.
func namesIn(dir string) *listing {
entries, err := os.ReadDir(dir)
if errors.Is(err, fs.ErrNotExist) {
return map[string]struct{}{}
return &listing{exact: map[string]struct{}{}, folded: map[string][]string{}}
}
if err != nil {
return nil
}
taken := make(map[string]struct{}, len(entries))
l := &listing{exact: make(map[string]struct{}, len(entries)), folded: make(map[string][]string, len(entries))}
for _, e := range entries {
taken[e.Name()] = struct{}{}
l.exact[e.Name()] = struct{}{}
key := collisionKey(e.Name())
l.folded[key] = append(l.folded[key], e.Name())
}
return taken
return l
}

// isTaken answers whether a name is already in use - from the listing when
// there is one, and from the filesystem when there is not.
// holds answers whether a name is already in use - from the listing when there
// is one, and from the filesystem when there is not - and, when it is in use
// under another spelling, which one.
//
// The two are not quite the same question and the listing asks the better one.
// os.Stat follows a link, so a link pointing at nothing answered "no name
// here" and the run went on to replace it. A directory ENTRY is what a name
// being taken means, whatever it points at, and the rule this serves is that
// nothing already there is written over. So the listing refuses a little more
// than the stat did, in the direction that cannot lose somebody's work.
func isTaken(taken map[string]struct{}, path, name string) bool {
if taken == nil {
return exists(path)
// The listing asks the better question of the two. os.Stat follows a link, so
// a link pointing at nothing answered "no name here" and the run went on to
// replace it. A directory ENTRY is what a name being taken means, whatever it
// points at, and the rule this serves is that nothing already there is written
// over. So the listing refuses a little more than the stat did, in the
// direction that cannot lose somebody's work.
//
// The listing compared spellings alone until 2026-10-27, and on a system that
// ignores case that answered "free" for files_0001.txt beside FILES_0001.TXT:
// --dry-run said nothing was in the way, and the run wrote the other files and
// its manifest before the rename met the name (docs/STABILITY-REVIEW-2026-10-
// 26.md, R07). A folded match is only a candidate, though. FoldName answers
// whether two names could EVER be one file, on some system, and this has to
// answer whether they are one on THIS one - a Linux directory holds both
// spellings side by side and a run there is right to write its own. So the
// system is asked about the path itself: Lstat finds it where the two are one
// name, and not where they are two. Any answer other than "not there" counts
// as taken, the same direction as above. The spelling named is the one that is
// the same file as what Lstat found, and none is named when no candidate is -
// the refusal then shows the path alone rather than a guess.
//
// What this cannot see, named rather than implied: a pair a filesystem joins
// and FoldName does not. None is known - core/foldname.go records that
// cases.Fold agreed with the filesystems on every pair tools/probes/casefold
// asked about - and such a pair would still meet the protection at the
// rename, as every case did before.
func (l *listing) holds(path, name string) (bool, string) {
if l == nil {
return exists(path), ""
}
if _, ok := l.exact[name]; ok {
return true, ""
}
candidates := l.folded[collisionKey(name)]
if len(candidates) == 0 {
return false, ""
}
here, err := os.Lstat(path)
if errors.Is(err, fs.ErrNotExist) {
return false, ""
}
if err != nil {
return true, ""
}
dir := filepath.Dir(path)
for _, onDisk := range candidates {
if there, err := os.Lstat(filepath.Join(dir, onDisk)); err == nil && os.SameFile(here, there) {
return true, onDisk
}
}
_, ok := taken[name]
return ok
return true, ""
}

// tempPathFor is the name a file is written under before it is renamed into
Expand Down
26 changes: 19 additions & 7 deletions internal/format/opc/opc.go
Original file line number Diff line number Diff line change
Expand Up @@ -269,7 +269,11 @@ func write(ctx context.Context, w io.Writer, p Package, withPadding bool) error
// check it and once to write it. Both passes are the same arithmetic
// from the same seed, which is what makes them agree.
head := &zip.FileHeader{Name: FillerName, Method: zip.Store, Modified: fixedTime}
head.CRC32 = fillerChecksum(p.Seed, p.Filler)
sum, err := fillerChecksum(ctx, p.Seed, p.Filler)
if err != nil {
return err
}
head.CRC32 = sum
head.UncompressedSize64 = uint64(p.Filler)
head.CompressedSize64 = uint64(p.Filler)
entry, err := zw.CreateRaw(head)
Expand Down Expand Up @@ -353,14 +357,22 @@ func (c *compressor) press(body []byte) ([]byte, error) {
}

// fillerChecksum is the first of the two passes over the padding.
func fillerChecksum(seed uint64, n int64) uint32 {
//
// It takes the write's context, because at the zip32 ceiling this pass is four
// gigabytes of generated bytes. Until 2026-10-27 it ran under a context of its
// own and a stop waited for all of them - about half a second for a 2 GB docx,
// where a txt of the same size stopped in 0.02 s
// (docs/STABILITY-REVIEW-2026-10-26.md, R09). The stop comes back as the
// error, so no header is ever written with a checksum of a pass that did not
// finish - the old code handed back 0 for that case.
func fillerChecksum(ctx context.Context, seed uint64, n int64) (uint32, error) {
sum := crc32.NewIEEE()
if err := writeFiller(context.Background(), sum, seed, n); err != nil {
// writeFiller only fails on a write error or a cancelled context, and
// a hash never refuses a write.
return 0
if err := writeFiller(ctx, sum, seed, n); err != nil {
// writeFiller only fails on a write error or a stopped context, and a
// hash never refuses a write, so this is the stop.
return 0, err
}
return sum.Sum32()
return sum.Sum32(), nil
}

// comment is filler a person can read, because a reader that shows the archive
Expand Down
Loading
Loading