Add per-test temporary directory (TestContext.TestTempDirectory) — RFC 021 - #10233
Conversation
Adds a new additive TestContext.TestTempDirectory property returning a directory unique to the currently executing test, created lazily on first access, living under the results directory, deleted on pass and retained on failure (with an env-var escape hatch to retain everything). Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 42f2a1fb-dd18-4ca3-8792-403ad874795e
Covers lazy creation, uniqueness across contexts and data-driven clones, delete-on-pass, retain-on-failure, env-var retention, cleanup error swallowing, and temp-path fallback. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 42f2a1fb-dd18-4ca3-8792-403ad874795e
Proves end-to-end: uniqueness across concurrently running tests, uniqueness across [DataRow] cases, lazy creation (no directory when never accessed), cleanup on pass, and retention on failure. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 42f2a1fb-dd18-4ca3-8792-403ad874795e
Address code-review findings: - Read the lazy-init state under the same lock the getter writes it under, so a directory created on a worker thread the test spawned but did not join cannot be observed as stale during Dispose (which would silently leak the directory even on a passing test). - Guard the cleanup-failure trace-log call so a misbehaving logger can never let an exception escape Dispose. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 42f2a1fb-dd18-4ca3-8792-403ad874795e
Second review round (documentation accuracy): - Do not claim cleanup is time-bounded / 'never hangs': it is a synchronous best-effort delete that runs after the test completes, so it cannot extend or fail the test, but a pathologically stalled filesystem is explicitly out of scope. - Soften absolute-uniqueness wording to reflect that uniqueness comes from the 48-bit GUID suffix (CreateDirectory is not an exclusive create); document the suffix-only name for empty/all-invalid names. - Record the cleanup-failure behavior as implemented (trace-only, guarded), and move the now-decided env-var name / length budgets / warning policy out of 'Unresolved questions' into settled decisions. - Note the retention/outcome-ordering contract (final outcome set before Dispose, default outcome is Failed so the fail-safe is retain). - Qualify the Rust (community crate) and Go ecosystem comparisons. - Mark the Implementation status box. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 42f2a1fb-dd18-4ca3-8792-403ad874795e
- Avoid slicing through a surrogate pair when truncating a long test name, which could leave a lone surrogate in the directory segment. - Add a unit test covering a long, data-driven-style name full of path-invalid characters: asserts the resulting segment contains no invalid characters and stays within the bounded length budget. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 42f2a1fb-dd18-4ca3-8792-403ad874795e
There was a problem hiding this comment.
Pull request overview
Adds RFC 021’s lazy, per-test temporary directory API to MSTest, including lifecycle management, API tracking, and automated coverage.
Changes:
- Adds
TestContext.TestTempDirectory. - Creates unique directories and applies outcome-based cleanup.
- Adds unit, acceptance, and RFC documentation coverage.
Show a summary per file
| File | Description |
|---|---|
src/TestFramework/TestFramework.Extensions/TestContext.cs |
Declares and documents the public property. |
src/Adapter/MSTestAdapter.PlatformServices/Services/TestContextImplementation.cs |
Implements creation, naming, retention, and cleanup. |
src/Adapter/MSTestAdapter.PlatformServices/InternalAPI/InternalAPI.Unshipped.txt |
Tracks the internal override. |
src/TestFramework/TestFramework.Extensions/PublicAPI/net462/PublicAPI.Unshipped.txt |
Tracks the net462 API. |
src/TestFramework/TestFramework.Extensions/PublicAPI/netstandard2.0/PublicAPI.Unshipped.txt |
Tracks the netstandard2.0 API. |
src/TestFramework/TestFramework.Extensions/PublicAPI/net8.0/PublicAPI.Unshipped.txt |
Tracks the net8.0 API. |
src/TestFramework/TestFramework.Extensions/PublicAPI/net9.0/PublicAPI.Unshipped.txt |
Tracks the net9.0 API. |
test/UnitTests/MSTestAdapter.PlatformServices.UnitTests/Services/TestContextImplementationTests.cs |
Covers creation, uniqueness, cleanup, and retention. |
test/IntegrationTests/MSTest.Acceptance.IntegrationTests/PerTestTempDirectoryTests.cs |
Adds end-to-end behavior validation. |
docs/RFCs/021-Per-Test-Temporary-Directory.md |
Documents motivation, design, and compatibility. |
Review details
- Files reviewed: 10/10 changed files
- Comments generated: 4
- Review effort level: Medium
This comment has been minimized.
This comment has been minimized.
Address reviewer finding F1: the fixed 50-char name budget protected no real headroom, because it ignored how deep the base path already was. On a deep results directory the total path could leave only a handful of characters, and the failure surfaced as a PathTooLongException from the user's own File.WriteAllText rather than from the framework. - Size the readable-name budget adaptively from the base-path length so that base + name + suffix plus a reserved 80-char headroom stays within MAX_PATH on Windows. Keep 50 as the cap, 8 as the floor. - When the results directory is too deep to preserve the headroom, fall back to the short system temp directory (documented behavior, not just the availability fallback). - Document the headroom guarantee and the 'targets 260, no long-path opt-in' stance (F2) in the property XML doc and RFC 021. - Add unit tests: deep-base-path fallback, and a non-BMP/surrogate-pair name that must not be split by truncation (F3 test coverage). Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 42f2a1fb-dd18-4ca3-8792-403ad874795e
There was a problem hiding this comment.
Review details
Comments suppressed due to low confidence (2)
src/Adapter/MSTestAdapter.PlatformServices/Services/TestContextImplementation.cs:615
Path.GetInvalidFileNameChars()is OS-specific; on Linux/macOS it does not include:,*,?,\\,<,>, or|. Those characters therefore survive sanitization, so the new cross-platform unit test at lines 765-767 fails and generated names are not portable as documented. Add the stable Windows-invalid/control-character set in addition to the platform-provided set.
if (char.IsWhiteSpace(c) || Array.IndexOf(invalidChars, c) >= 0)
src/Adapter/MSTestAdapter.PlatformServices/Services/TestContextImplementation.cs:707
- This retention decision can see a stale passing outcome.
TestMethodInfo.InvokeAsynccallsSetOutcome(result.Outcome)beforeRunTestCleanupMethodAsync, while that cleanup method can subsequently change a passing result to Failed/Inconclusive without updating the context. Consequently, if the test body passes but[TestCleanup],Dispose, or a global cleanup fails, this code deletes the directory even though the final test result is non-passing, violating the advertised retain-on-failure contract. Propagate the post-cleanup/final outcome to this context before disposal and cover that lifecycle case.
if (_outcome != UnitTestOutcome.Passed)
- Files reviewed: 10/10 changed files
- Comments generated: 0 new
- Review effort level: Medium
This comment has been minimized.
This comment has been minimized.
…tch clauses - Sanitize the Windows-reserved/control character set explicitly rather than relying only on Path.GetInvalidFileNameChars(), which on Unix omits the Windows-illegal characters, so generated names are portable and the sanitization test is deterministic on non-Windows. (reviewer) - Re-sync the folded data-driven iteration context's outcome with the final post-cleanup result before disposing the per-row clone. The framework sets the context outcome before running [TestCleanup]/Dispose, so a row whose body passes but whose cleanup fails would otherwise have its TestTempDirectory deleted instead of retained. Fixed in both folded paths (DataRow + DataSource); regression covered by a unit test and an acceptance test. (reviewer) - Replace bare catch clauses with catch (Exception) in the cleanup trace-log guard and the two best-effort test cleanup helpers. (CodeQL) - Unlink the not-yet-merged RFC 020 reference so the relative link is not dead on main. (reviewer) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 42f2a1fb-dd18-4ca3-8792-403ad874795e
There was a problem hiding this comment.
Review details
Comments suppressed due to low confidence (2)
src/Adapter/MSTestAdapter.PlatformServices/Services/TestContextImplementation.cs:592
- This budget allows the returned path itself to be 180 characters, so appending 80 characters produces a 260-character visible path. Classic Win32
MAX_PATHincludes the terminating NUL, leaving only 259 visible characters; the documented 80-character headroom therefore fails at the boundary. Reserve one additional character for the terminator.
=> WindowsMaxPath
src/Adapter/MSTestAdapter.PlatformServices/Services/TestContextImplementation.cs:581
- The adaptive calculation budgets for a 12-character suffix, but this fallback substitutes a 32-character GUID. With a deep base path, this rare branch consumes 20 characters of the promised 80-character headroom and can cross the classic path limit. Recompute the fallback budget or place the full-GUID fallback under a sufficiently short root.
string fallback = Path.Combine(baseDirectory, Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(fallback);
- Files reviewed: 12/12 changed files
- Comments generated: 3
- Review effort level: Medium
This comment has been minimized.
This comment has been minimized.
… alignment - Fall back to the system temp directory when the chosen base directory cannot be written to (e.g. a read-only output directory). Because TestResultsDirectory maps to the test assembly's output directory when no results directory is configured, it is rarely empty, so the previous "unavailable" fallback almost never fired and a read-only base would throw from the property getter. Directory creation now catches IOException/UnauthorizedAccessException and retries under system temp. Covered by a new unit test. (reviewer) - Nest the deep-results-path unit test's parent directory under a TempDirectoryScope so that on non-Windows (where no MAX_PATH fallback occurs and the parent is actually created) it is reclaimed instead of leaking into the temp folder. (reviewer) - Align the RFC's (sign-off) decisions with the PR description: reword the intro to say the items are proposals pending ratification (not already confirmed), and drop the (sign-off) marker from lazy creation so both sources list the same three decisions (name, cleanup policy, location). (reviewer) - Clarify the property XML doc and RFC section 5 that the temp fallback also covers an unwritable results directory, and that the results directory defaults to the assembly output directory. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 42f2a1fb-dd18-4ca3-8792-403ad874795e
There was a problem hiding this comment.
Review details
Comments suppressed due to low confidence (1)
src/Adapter/MSTestAdapter.PlatformServices/Services/TestContextImplementation.cs:562
- The 80-character headroom is not preserved when the Windows system temp path is itself long (for example, via a custom
TEMP). After falling back,availablemay still be negative, but clamping the readable budget to zero only removes the name; the 12-character suffix remains. A temp root longer than 167 characters therefore returns a directory path longer than 180 characters, contradicting the public XML/RFC guarantee, and a still-deeper root can make the final GUID fallback throw under classicMAX_PATH. Validate the minimum suffix-only path against the budget and either select a genuinely shorter writable root or qualify the documented guarantee.
baseDirectory = Path.GetTempPath();
baseIsTemp = true;
available = ComputeReadableNameBudget(baseDirectory);
}
nameBudget = available < 0 ? 0 : Math.Min(available, TestTempDirectoryNameMaxLength);
- Files reviewed: 12/12 changed files
- Comments generated: 2
- Review effort level: Medium
This comment has been minimized.
This comment has been minimized.
…tion race - Use a full 32-hex-char (128-bit) GUID as the uniqueness suffix instead of 12 hex chars. Directory.Exists + CreateDirectory is not an atomic exclusive create, so two contexts choosing the same suffix concurrently could share a directory. A full GUID makes that birthday collision cryptographically negligible even at millions of contexts, so the documented uniqueness guarantee is credible without relying on the (non-atomic) pre-check. (reviewer) - Close a post-dispose creation race: if Dispose acquired the temp-dir lock before a concurrent first getter, it saw not-created and returned, then the getter created a directory that would never be cleaned up. A cleanup-started flag is now set under the lock in cleanup, and the getter returns null without creating once it is set. Covered by a new unit test (access after Dispose creates nothing). (reviewer) - Update the RFC, XML doc, and length-bound unit test to reflect the 128-bit GUID suffix. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 42f2a1fb-dd18-4ca3-8792-403ad874795e
- Include SecurityException in the directory-creation catch filter. On .NET Framework a denied filesystem permission surfaces as SecurityException; without it an inaccessible base directory escaped the getter instead of falling back to system temp. - Guard Environment.GetEnvironmentVariable in the retention check against SecurityException (restricted environments on .NET Framework), matching the existing pattern in TestDataConnection. The call is reached from Dispose, so an unguarded throw would break the best-effort contract; an inaccessible retention setting is now treated as unset. - Correct the cleanup XML doc: it swallows exceptions (never throws) and runs after the test so it cannot extend/trip the test timeout, but a synchronous delete is not time-bounded — drop the "cannot hang" claim. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 42f2a1fb-dd18-4ca3-8792-403ad874795e
There was a problem hiding this comment.
Review details
Comments suppressed due to low confidence (1)
src/Adapter/MSTestAdapter.PlatformServices/Services/TestContextImplementation.cs:606
- Clamping a negative budget to zero does not preserve the documented 80-character headroom when the system temp root itself is deep. Windows allows
TMP/TEMPto point to an arbitrarily long path; once the results path falls back there,TryCreateTestTempDirectoryUndercan still succeed on long-path-enabled .NET and return<deep-temp>\<guid>longer than 180 characters, even though this API explicitly promises compatibility with classicMAX_PATHconsumers. Validate that the GUID-only candidate fits the budget too; otherwise use a genuinely shorter base or fail/document that the guarantee cannot be met. The final fallback at lines 629-633 needs the same check.
available = ComputeReadableNameBudget(baseDirectory);
}
nameBudget = available < 0 ? 0 : Math.Min(available, TestTempDirectoryNameMaxLength);
- Files reviewed: 12/12 changed files
- Comments generated: 1
- Review effort level: Medium
This comment has been minimized.
This comment has been minimized.
…ult file Address PR review: a passing test could write a file into TestTempDirectory and register it via TestContext.AddResultFile, but delete-on-pass ran (in Dispose) before the host collected the attachment. The MTP FileArtifactProperty and the VSTest attachment URI both reference the original path, so the attachment pointed at a deleted file. The framework consumes the result-file list during execution (TestMethodInfo sets result.ResultFiles via GetResultFiles, which clears the list) before the context is disposed, so cleanup cannot consult that list. Instead, AddResultFile now sets a persistent flag when the registered file lives inside the temp directory, and cleanup retains the directory on pass when that flag is set. Covered by unit tests (retained when a result file is under the dir; still deleted when the result file is elsewhere) and an end-to-end acceptance test (a passing test that AddResultFile's a file from its temp dir keeps the file). Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 42f2a1fb-dd18-4ca3-8792-403ad874795e
There was a problem hiding this comment.
Review details
Comments suppressed due to low confidence (1)
src/Adapter/MSTestAdapter.PlatformServices/Services/TestContextImplementation.cs:628
- If the configured Windows temp root is itself deep,
availableis still negative after the results-directory fallback. Clamping only the readable prefix to zero cannot recover the promised 80-character headroom because the 32-character GUID suffix is irreducible; on modern .NET with long paths enabled, creation can succeed and return a path that classic-MAX_PATH external tools cannot use. Validate the complete temp candidate against the headroom budget and either choose a genuinely shorter base or fail explicitly instead of returning a path that violates the API guarantee.
nameBudget = available < 0 ? 0 : Math.Min(available, TestTempDirectoryNameMaxLength);
- Files reviewed: 12/12 changed files
- Comments generated: 1
- Review effort level: Medium
This comment has been minimized.
This comment has been minimized.
Address PR review: the "result file registered under the temp directory" marker was sticky across retry attempts on the reused TestContextImplementation. If an earlier (failed) attempt registered an in-directory result file but the final reported attempt passed without one, the marker stayed true and the passing test's directory was retained indefinitely. GetResultFiles runs once per execution attempt (TestMethodInfo consumes the list before disposal), so it now recomputes the marker from the current attempt's result files rather than leaving the accumulated value. The last attempt before disposal — the reportable one — therefore determines retention. The eager set in AddResultFile is kept for immediate consistency and for hosts that never call GetResultFiles. Added a deterministic retry regression: attempt 1 registers an in-directory result file and is consumed; attempt 2 registers none; the passing directory is deleted. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 42f2a1fb-dd18-4ca3-8792-403ad874795e
The cleanup contract now retains a passing test's directory when the test registers a file inside it as a result attachment via AddResultFile (the host collects the attachment after disposal). Record this exception in the property XML doc and RFC 021's cleanup-policy section so consumers do not assume every passing test directory is removed. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 42f2a1fb-dd18-4ca3-8792-403ad874795e
…st-temp-directory # Conflicts: # src/Adapter/MSTestAdapter.PlatformServices/InternalAPI/InternalAPI.Unshipped.txt
This comment has been minimized.
This comment has been minimized.
🧪 Test quality grade — PR #1023324 tests graded across 2 files (4 integration, 20 unit). 23 earn A and 1 earns B. The suite is exceptionally well-written: every test follows a clear Arrange-Act-Assert pattern, assertions are concrete and multi-faceted, and the mutation story is nearly perfect. The single B is
This advisory comment was generated automatically. Grades are heuristic
|
Summary
Adds a per-test unique temporary directory to MSTest, exposed as a new additive
TestContext.TestTempDirectoryproperty, and the accompanying RFC 021. This closes a genuine gap: every other major test ecosystem ships a per-test temp directory (Got.TempDir(), pytesttmp_path, JUnit 5@TempDir, Rusttempfile::TempDir, PlaywrightoutputDir) and .NET has none. MSTest V1 actually gave each test a uniqueTestResultsDirectory; V2 collapsed it — reported as #502 and closed won't-fix. In hindsight, that was this feature.Without it, tests that write to disk hardcode shared paths (which race under parallel execution and force
[DoNotParallelize]) or hand-roll aGetTempPath()+Guid+IDisposablehelper — copy-pasted across the ecosystem, including this very repo'sTempDirectory.cs.Behavior
public virtual string? TestContext.TestTempDirectory— additive; no existing directory property changes semantics.[DataRow]/[DynamicData]case (each case runs on its ownTestContext). Uniqueness comes from a full 128-bit GUID suffix, so it holds even for many cases of the same method running concurrently.TestResultsDirectory(discoverable next to other run output). The readable directory name is not a fixed short string — its length is computed adaptively from how deep the base path already is (capped at 50 characters), always reserving a working headroom of 80 characters for the files the test itself writes so ordinary relative writes inside it will not overflowMAX_PATH.Path.GetTempPath()happens when the results directory is unavailable, when it is so deep that even a minimal readable name cannot preserve the 80-char headroom, or when the chosen base directory cannot be written to (e.g. a read-only output directory). NoteTestResultsDirectorydefaults to the test assembly's output (bin) directory when no results directory is configured, so by default the per-test directory is created there.MAX_PATHand does not rely on long-path opt-in (LongPathsEnabled/ the\\?\prefix), which is not guaranteed to be enabled and is often not honored by external tools that E2E tests shell out to.MSTEST_TEST_TEMP_DIRECTORY_RETAIN=1retains everything. Best-effort inDispose: never throws (leaked handles / AV locks are swallowed and traced), and runs after the test so it can't extend or fail it.See
docs/RFCs/021-Per-Test-Temporary-Directory.mdfor the full motivation, all 9 design questions answered with rationale, an explicit comparison table against the existing five directory properties, and the relationship to RFC 020 ([ResourceLock]): eliminate the sharing (this) before you coordinate access to it (ResourceLock).Decisions needing sign-off⚠️
These are called out in the RFC as
**(sign-off)**and should be ratified, not assumed settled:TestTempDirectory(chosen to be unambiguous vs the existing five directory properties).TestResultsDirectory, with the adaptive-budget + temp-path fallback described above (vs alwaysPath.GetTempPath()).MSTEST_TEST_TEMP_DIRECTORY_RETAINescape hatch (vs always-delete or a runsettings knob, which is deferred).Secondary settled-but-tunable values: readable-name cap 50, floor 8, unique suffix 32 hex chars (128-bit GUID), reserved headroom 80; cleanup-failure is trace-only (no test warning).
Non-goals (in RFC)
Environment.CurrentDirectory(process-global; corrupts parallel tests).Tests
MSTestAdapter.PlatformServices.UnitTests): lazy creation, uniqueness across contexts and across data-driven clones, delete-on-pass, retain-on-failure, env-var retain, cleanup swallows errors,GetTempPath()fallback (unavailable / too-deep / unwritable base), post-dispose no-create race, folded-row cleanup-failure retention, long/invalid-name sanitization bounds, and a non-BMP / surrogate-pair name that truncation must not split.MSTest.Acceptance.IntegrationTests): end-to-end proof of uniqueness across concurrently running tests, uniqueness across[DataRow]cases, lazy creation (no directory for a test that never accesses it), cleanup on pass, retention on failure, and retention of folded[DataRow]rows whose[TestCleanup]fails.Validated with
.\build.cmd -pack -test(green across all TFMs) and the acceptance tests passing.Notes
New public API added to the four relevant
PublicAPI.Unshipped.txtfiles; noinitaccessor. This branch is independent of the sibling[ResourceLock]branch — RFC 020 was read for alignment only and is not modified.