Skip to content

fix: one directory holds one run at a time - #79

Merged
donislawdev merged 1 commit into
mainfrom
fix/shared-directory-lock
Sep 7, 2026
Merged

fix: one directory holds one run at a time#79
donislawdev merged 1 commit into
mainfrom
fix/shared-directory-lock

Conversation

@donislawdev

Copy link
Copy Markdown
Owner

Two runs writing into one directory could write over each other's files, and
both report that they had succeeded.

What was measured

Two processes started on the same wall clock instant, sixty files of 200 kB
each, the same name template, different output.manifest, eight times:

outcome how often what it means
both ended 0 2 of 8 120 files reported, 60 on the disk
both ended 8 5 of 8 partial, and not a defence - see below
one ended 5 1 of 8 the preflight happened to see the other run

In the first case, tfg verify against the first run's manifest reported
sixty wrong hashes about a run that had been told it succeeded.

The middle row is not protection. Windows refuses to rename onto a file another
process holds open, and Linux does not, so the same eight attempts on Linux
would be silent seven times rather than twice.

Starting the two runs with & in a shell does not reproduce this. The
second process is milliseconds behind and its preflight sees the first run's
files. Both have to be released on a shared timestamp.

Why the fix is small

The protection already existed and was attached to the wrong thing. A run claims
its manifest name before the first file and keeps it until it ends, so two runs
both writing manifest.json into one directory have always been refused -
measured the same day, four times out of four. output.manifest is the one
way out of that claim, and it was never meant to be a way out of this.

So this widens the key of the claim that is already there:

  • a run takes core.RunLockName in the output directory, holds it for its whole
    length, and gives it back however it ends, interruption included,
  • the preflight asks about it before the manifest name. That is what a dry
    run needs, and it is also the truer sentence: a second run used to be told
    that manifest.json already exists and is the only record of what an earlier
    run wrote, about a run that was still going,
  • verify names the lock as ours rather than as a file somebody else left. That
    makes it the third marker to need this repair, after the partial and the
    writing ones, and its sentence holds both endings open because it is the only
    one of the three that may belong to a run still in flight.

Eight of eight refused after the change.

The cost, stated in the refusal and in the changelog

  • A run killed outright cannot give the name back, so the next run into that
    directory is refused until a person deletes .tfg-run-lock. The refusal names
    the file. cleanup will not remove it, because it removes only what a manifest
    lists.
  • Two runs can no longer fill one directory at the same time, even when the
    names they write do not collide. For every run that does not set
    output.manifest, that was already true.

Checking whether the process id in a stale lock is still alive was considered
and refused: process ids are reused, and on a network share the question is
meaningless. A wrong "this lock is dead" reproduces exactly the data loss this
closes.

Guards

Six guards in internal/guard/runlock_test.go, six mutations, all caught. Two
of the mutations found faults in the guards rather than in the code:

  • the stopped-run guard cancelled the context before the run, so the
    preflight refused, the lock was never taken, and "it is gone afterwards" was
    true of nothing. It now cancels from inside the run and asserts the lock was
    held at that moment.
  • a seventh guard proved exactly what the first one proves, because the lock
    never sees the manifest name at all. Folded into one rather than given an
    excuse on the not-proven list.

staleness.py then found three existing mutations this change had
invalidated - two patterns in audit.go and one that stopped being unique in
engine.go. Repaired, and every pattern occurs exactly once again.

Full suite green, preflight --quick green on all twelve checks.

🤖 Generated with Claude Code

Two runs writing into one directory used to write over each other's files and
both report that they had succeeded.

Measured with two processes started on the same wall clock instant, sixty files
of 200 kB each, eight times:

  both ended 0    2 of 8   120 files reported, 60 on the disk
  both ended 8    5 of 8   partial, and only because Windows refuses to
                           rename onto a file another process holds open
  one ended 5     1 of 8   the preflight happened to see the other run

In the first case verify against the first run's manifest reported sixty wrong
hashes about a run that had been told it succeeded. The middle case is not a
defence: it is a property of one system, and Linux renames onto a file another
process has open without complaint.

The protection already existed and was keyed to the wrong thing. A run claims
its manifest name before the first file and keeps it until it ends, so two runs
both writing manifest.json into one directory have always been refused -
measured the same day, four times out of four. output.manifest is the one way
out of that claim, and it was never meant to be a way out of this.

So this widens the key of the claim that is already there rather than adding a
second mechanism. A run takes core.RunLockName in the output directory, holds
it for its whole length, and gives it back however it ends, interruption
included. The preflight asks about it before it asks about the manifest name,
which is what a dry run needs, and is also the better sentence: a second run
used to be told that manifest.json already exists and is the only record of
what an earlier run wrote, about a run that was still going.

Eight of eight refused after the change.

verify names the lock as ours rather than as a file somebody else left, which
makes it the third marker to need that repair after the partial and the writing
ones. Its sentence holds both endings open, because it is the only one of the
three that may belong to a run still in flight.

The cost is stated in the refusal, in the changelog and in the document. A run
killed outright cannot give the name back, so the next run into that directory
is refused until a person deletes the file. And two runs can no longer fill one
directory at the same time, even when the names they write do not collide - for
every run that does not set output.manifest, that was already true.

Six guards, six mutations. Two of the mutations found faults in the guards
rather than in the code: the stopped-run guard cancelled BEFORE the run, so the
lock was never taken and "it is gone afterwards" was true of nothing, and a
seventh guard proved exactly what the first one proves, because the lock never
sees the manifest name at all.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
@donislawdev
donislawdev merged commit 05ed2dd into main Sep 7, 2026
18 checks passed
@donislawdev
donislawdev deleted the fix/shared-directory-lock branch September 7, 2026 11:17
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant