Skip to content

Give each test run its own temp directory (BL-16664) - #8172

Merged
andrew-polk merged 9 commits into
masterfrom
BL-16664-isolate-test-temp
Aug 7, 2026
Merged

Give each test run its own temp directory (BL-16664)#8172
andrew-polk merged 9 commits into
masterfrom
BL-16664-isolate-test-temp

Conversation

@JohnThomson

@JohnThomson JohnThomson commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

Our C# tests name their scratch folders after themselves — about 180 calls of the form new TemporaryFolder("SomeFixtureTests") — and those names resolve to machine-global paths. That was harmless when one person ran the suite at a time. It is not harmless now that agents work in several worktrees at once, because TemporaryFolder's constructor deletes any existing folder of that name before creating it:

public TemporaryFolder(string name)
{
    _path = Path.Combine(Path.GetTempPath(), name);
    if (Directory.Exists(_path))
        DeleteFolderThatMayBeInUseAndIfNotFailSilently(_path);   // <-- someone else's live folder
    Directory.CreateDirectory(_path);
}

So one run's setup silently deletes another run's in-flight folder, and the victim then fails somewhere unrelated, naming a folder it never heard of.

The fix

Rather than rename 180 call sites — which would still leave Bloom's own production code writing to the shared temp directory while under test — redirect the whole process's temp directory before any fixture runs, with an assembly-wide [SetUpFixture]:

%TEMP%\BloomTests\<key>-p<pid>\

Every existing call site keeps its familiar name; the name is now scoped to this run. Path.GetTempPath() is defined in terms of TMP/TEMP, so this also covers the dozen tests that build temp paths by hand, and production code under test.

The <key> half comes from BLOOM_AGENT_BUILD_DIR when build/agent-dotnet.sh set it, so a temp folder can be matched to the build tree beside it; the pid is what guarantees uniqueness.

It lives in the global namespace deliberately: an NUnit [SetUpFixture] applies to its own namespace and those beneath it, and not quite all our fixtures are under BloomTests (there is one in Bloom.Api).

A failing run keeps its folder so the evidence survives — these bugs are precisely about temp folders appearing and disappearing, so deleting the scene of the crime would be perverse. Passing runs delete theirs; anything older than a day is cleared by a later run.

Effect on the suite

Measured on one developer machine, whose %TEMP% holds ~25,000 entries:

Tree Total run Passed Failed Aborted?
pristine master 1678 1603 65 yes
this branch 3046 3032 1 no

The suite ran to completion, and two consecutive runs gave identical results — so this is deterministic, not luck. The remaining failure, XMatterHelperTests.InsideBackCover_PaperAndDeviceXMatter_ShareOneCustomLayoutId, also fails on pristine master and is unrelated to temp; note that AGENTS.md calls a failing xmatter-locating test a real wrapper/Directory.Build.props regression, so it is worth chasing separately.

Tests

Two, asserting the invariant directly: that Path.GetTempPath() is this run's own folder and not the machine's, and that an ordinary fixed-name TemporaryFolder lands inside it.

Ref: https://issues.bloomlibrary.org/youtrack/issue/BL-16664


This change is Reviewable


Devin review

Devin review

Our tests name their scratch folders after themselves -- about 180 calls of the
form new TemporaryFolder("SomeFixtureName") -- and those names resolve to
machine-global paths. TemporaryFolder's constructor deletes any existing folder
of the name before creating it, so when two runs share a machine (agents working
in several worktrees at once) one run's setup silently deletes another run's
in-flight folder, and the victim fails somewhere unrelated.

Rather than rename 180 call sites -- which would still leave Bloom's own code
writing to the shared temp directory while under test -- redirect the whole
process's temp directory before any fixture runs, via an assembly-wide
[SetUpFixture] in the global namespace. Every existing call site keeps its
familiar name, now scoped to this run, and production code under test is
isolated too.

A failing run keeps its folder so the evidence survives; passing runs delete
theirs, and anything older than a day is cleared by a later run.

WIP: committed locally to keep the work safe while the BL-16661 PR is finished.
Not pushed, no PR yet.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Comment thread src/BloomTests/TestTempDirectory.cs Outdated
JohnThomson and others added 2 commits August 6, 2026 17:07
Devin's review of PR #8172 pointed out that the two public entry points --
RedirectTempToAFolderOfOurOwn and RemoveOurTempFolderUnlessSomethingFailed --
had no documentation comment, which AGENTS.md ("# Commenting") requires of all
public methods. The private helpers were documented; these were not. The two
test methods get summaries for the same reason.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Prompted by Devin's review of PR #8172, which asked how anyone would confirm the
keep-on-failure behaviour. Checking it turned up a wrong claim in the note I had
just added: it said the path of a kept folder "is printed at the end of the
run". The code does report it, but through NUnit's progress channel, which
dotnet test does not show at its default verbosity -- so a developer following
that sentence would look in the output, see nothing, and conclude the folder had
not been kept.

Say where to look instead.

Verified separately that the behaviour itself is right: a run of one failing
test leaves its folder behind, and a run of one passing test creates and then
removes its own.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Comment thread src/BloomTests/TestTempDirectory.cs Outdated
Devin's review of PR #8172 spotted that teardown deleted the run folder but left
TMP and TEMP pointing at it. Anything running after that point -- NUnit's own
shutdown, or a background thread outliving the tests -- would compute a temp
path inside a directory that no longer exists and fail confusingly at the very
end of an otherwise good run.

Restore the machine's own temp folder first, whether or not we go on to delete
ours.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Comment thread src/BloomTests/TestTempDirectory.cs Outdated
@JohnThomson

Copy link
Copy Markdown
Contributor Author

[Claude Opus 5 (1M context)] Consulted Devin on 2026-08-06 17:42 CDT up to commit 4ba357019edc6ddec694192d31b293740e14f3e7.

Four rounds, because each round's findings produced a fix commit, which starts a new review. Three things came out of it, each with its own resolved thread above:

  • The two public NUnit hooks had no documentation comments, which the repo's guidance requires. Added.
  • Nothing had actually verified the keep-on-failure behaviour, which depends on a corner of NUnit — whether a run's aggregated result is visible inside a global [SetUpFixture]'s teardown. Checked it directly: a run of one failing test leaves its folder behind, a run of one passing test creates and removes its own. That check also exposed a wrong sentence in the AGENTS.md note I had added, which is now corrected.
  • Teardown deleted the run folder but left TMP/TEMP pointing at it, so anything running after the tests would have been handed a path inside a directory that no longer existed. Now restored before the delete.

Two notes for anyone reading the thread list. Devin appears to accumulate findings across rounds rather than recomputing them against the current diff: the final round re-reported all three of the above against commit 4ba35701, even though every one of them is fixed in that very commit. They were not posted again, since each already has a documented, resolved thread. Devin also left four informational observations each round, which are not mirrored.

CI: this repo runs no build or test checks on pull requests, so there was no CI signal. Greptile has stayed queued throughout without reviewing.

JohnThomson and others added 4 commits August 7, 2026 08:57
Cleanup deleted the run's temp folder and said nothing if it failed. A failure
there is worth knowing about: it normally means a test finished without
disposing something it opened, which is a small bug of its own and can make
later runs behave oddly.

So after the delete attempt, if the folder is still there, report it -- naming
one file that will not open and the reason the operating system gave, e.g.
"1 file(s) remain; at least this one is still in use: <path> -- The process
cannot access the file ... because it is being used by another process."

Deliberately only a warning. A leaked handle should not turn a green run red at
the very last moment.

The message goes to standard error, which experiment shows is the only channel
dotnet test displays at its default verbosity -- Console.Out, TestContext.Out
and TestContext.Progress are all swallowed. The existing "tests failed, so their
files were left in ..." message moves there too, so it can actually be seen; it
was previously written to TestContext.Progress, where nobody would ever have
read it.

The part that decides what to report is a separate method so it can be tested:
one test locks a file and checks the message names that file rather than its
innocent neighbour, and carries the reason; another checks a folder that really
did go produces nothing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
From the local review pass. The warning reports the first file it cannot open
exclusively, and called it "still in use". That is the commonest cause but not
the only one: a read-only file, or an ACL problem, lands in the same catch. Say
the file could not be opened, and let the operating system's own message -- which
we already pass through -- distinguish the cases.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Devin's review of PR #8172 found a real hole. The run folder was only created,
never emptied, and its name is not unique forever: it is built from a process
id, Windows recycles those, and folders from crashed or failing runs are kept
for a day. So a run could be handed the path of a dead run and inherit whatever
it left there -- producing exactly the confusing stale-file failures this class
exists to prevent.

Empty it first. That is safe because a process id is unique among *running*
processes and this one is ours, so nothing alive can be using the folder.

Two tests: one puts stale files and a subfolder in a folder named the way a
recycled run would be and checks nothing survives, and one checks the ordinary
case where the folder does not exist yet.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Comment thread src/BloomTests/TestTempDirectory.cs Outdated
@JohnThomson

Copy link
Copy Markdown
Contributor Author

[Claude Opus 5 (1M context)] Consulted Devin on 2026-08-07 09:32 CDT up to commit e9188cba75c9ffa7ff9aeb66ffc08b85b9e291eb.

Second preflight, after the developer asked for a warning when the run's temp folder cannot be
deleted. Devin found one further bug, now fixed and its thread resolved above:

  • A run could inherit a dead run's scratch folder. The folder was created but never emptied,
    and its name is a label plus the process id — which Windows recycles — while folders from failing
    or crashed runs are deliberately kept for a day. So the two retention rules added in this branch
    were themselves widening the window for a run to start on top of stale files, which is the exact
    confusion the branch exists to remove. It now starts empty.

Its final round re-reported all four findings from this branch's history against e9188cba,
including that one — which is fixed in that very commit. As noted in the earlier log, Devin
accumulates findings across rounds rather than recomputing them, so nothing was posted twice; each
already has a documented, resolved thread.

Full suite at this commit: 3067 run, 3053 passed, 1 failed — XMatterHelperTests.InsideBackCover_PaperAndDeviceXMatter_ShareOneCustomLayoutId, which fails on pristine master too — and no abort.

One correction for the record: during this round MakePdf_BookNameIsNonAscii_OutputsPdf failed twice and I initially took it for a regression from this branch. It is not: it passed 5 of 5 subsequent runs in isolation and in two clean full runs. Both failures fell during a full-suite run that took 11 minutes rather than the usual 4-5, i.e. under heavy contention, and that test shells out to WebView2.

CI: this repo runs no build or test checks on pull requests. Greptile has stayed queued throughout without reviewing.

@JohnThomson
JohnThomson marked this pull request as ready for review August 7, 2026 14:33

@andrew-polk andrew-polk left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@andrew-polk reviewed 3 files and all commit messages, made 1 comment, and resolved 1 discussion.
Reviewable status: all files reviewed, 4 unresolved discussions (waiting on JohnThomson).


src/BloomTests/TestTempDirectory.cs line 28 at r2 (raw file):

there is a fixture in the Bloom.Api namespace

Seems like we should fix that.

Maybe doesn't change this other than the comment, of course.

@JohnThomson JohnThomson left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@JohnThomson made 1 comment.
Reviewable status: 2 of 4 files reviewed, 4 unresolved discussions (waiting on andrew-polk).


src/BloomTests/TestTempDirectory.cs line 28 at r2 (raw file):

Previously, andrew-polk wrote…

there is a fixture in the Bloom.Api namespace

Seems like we should fix that.

Maybe doesn't change this other than the comment, of course.

Done.

@andrew-polk andrew-polk left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@andrew-polk reviewed 2 files and all commit messages, and resolved 1 discussion.
Reviewable status: all files reviewed, 3 unresolved discussions.

@andrew-polk
andrew-polk merged commit 67c1a78 into master Aug 7, 2026
1 of 2 checks passed
@andrew-polk
andrew-polk deleted the BL-16664-isolate-test-temp branch August 7, 2026 19:56
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.

2 participants