Skip to content

Fix flaky tree-kill test by retrying the PID file read - #732

Merged
Nikola Metulev (nmetulev) merged 4 commits into
mainfrom
azchohfi-fix-treekill-pidfile-race
Aug 12, 2026
Merged

Fix flaky tree-kill test by retrying the PID file read#732
Nikola Metulev (nmetulev) merged 4 commits into
mainfrom
azchohfi-fix-treekill-pidfile-race

Conversation

@azchohfi

Copy link
Copy Markdown
Contributor

Problem

RunDotnetProcessAsync_Cancellation_KillsProcessTree fails intermittently on CI — it just took down build-and-package on #730, which is a docs/logging-only PR:

System.IO.IOException: The process cannot access the file
'...\winapp_treekill_dff98ff32ad34e73be394cf6119af1dc.pid' because it is being used by another process.
   at System.IO.File.InternalReadAllTextAsync(...)
   at DotNetServiceTests.WaitForPidFileAsync(...) DotNetServiceTests.cs:1864
   at DotNetServiceTests.RunDotnetProcessAsync_Cancellation_KillsProcessTree() ...:1748

Root cause

WaitForPidFileAsync polls for the PID file the spawned PowerShell root writes with Set-Content:

if (File.Exists(pidFile))
{
    var text = (await File.ReadAllTextAsync(pidFile, cancellationToken)).Trim();   // ← throws

File.Exists turns true the instant Set-Content creates the file, but PowerShell still holds the write handle for a short window afterwards. A read landing inside that window fails with a sharing violation, and the exception escaped the polling loop instead of being treated as "not ready yet".

Reproduced directly rather than inferred:

$fs = [System.IO.File]::Open($pidFile, 'Create', 'Write', 'None')
[System.IO.File]::Exists($pidFile)      # True
[System.IO.File]::ReadAllText($pidFile) # IOException: ... being used by another process

That is exactly the CI error, and it explains why it's load-dependent: the window is tiny, so it only loses the race on a busy agent.

Fix

Catch the transient IO failure and keep polling. A sharing violation here means "not written yet" — precisely the condition the loop already exists to wait out.

An empty or partially written file needed no new handling: the existing int.TryParse guard already keeps polling until the value parses.

Validation

  • Target test passed 3/3 locally after the change.
  • WaitForPidFileAsync has exactly one caller, so the blast radius is that one test.
  • No docs needed: check-docs keys off src/winapp-CLI/**/Commands/, and this is test-only.

RunDotnetProcessAsync_Cancellation_KillsProcessTree fails intermittently on CI:

  System.IO.IOException: The process cannot access the file
  '...\winapp_treekill_<guid>.pid' because it is being used by another process.
     at System.IO.File.InternalReadAllTextAsync(...)
     at DotNetServiceTests.WaitForPidFileAsync(...) DotNetServiceTests.cs:1864

The helper polls for the PID file that the spawned PowerShell root writes with
Set-Content. File.Exists turns true the moment Set-Content creates the file, but
PowerShell still holds the write handle for a short window after that, so a read
landing inside the window fails with a sharing violation. The exception escaped
the polling loop and failed the test.

Reproduced directly: hold a handle open with FileShare.None and File.Exists
returns true while File.ReadAllText throws that exact IOException.

A transient IO failure here just means "not written yet", which is the condition
the loop already exists to wait out, so it is caught and retried. An empty or
partially written file was already covered by the existing int.TryParse guard.

The target test passed 3/3 locally after the change. WaitForPidFileAsync has a
single caller, so nothing else is affected.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: a209763e-7185-4986-bf0b-98e52c06b4be
Copilot AI balanced review requested due to automatic review settings August 11, 2026 22:30

Copilot AI 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.

Pull request overview

Retries transient PID-file read failures to stabilize the process-tree cancellation test.

Changes:

  • Retries IOException while PowerShell finishes writing the PID file.
  • Documents the race condition and existing parse retry behavior.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread src/winapp-CLI/WinApp.Cli.Tests/DotNetServiceTests.cs
@github-actions

github-actions Bot commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

Build Metrics Report

Binary Sizes

Artifact Baseline Current Delta
CLI (ARM64) 38.62 MB 38.62 MB ✅ 0.0 KB (0.00%)
CLI (x64) 38.73 MB 38.73 MB ✅ 0.0 KB (0.00%)
MSIX (ARM64) 16.02 MB 16.02 MB 📈 +0.1 KB (+0.00%)
MSIX (x64) 17.01 MB 17.01 MB 📉 -0.4 KB (-0.00%)
NPM Package 33.42 MB 33.42 MB 📉 -0.3 KB (-0.00%)
NuGet Package 33.45 MB 33.45 MB 📈 +0.2 KB (+0.00%)

Test Results

4554 passed, 5 skipped out of 4559 tests in 556.2s (+3 tests, -25.0s vs. baseline)

Test Coverage

89.1% line coverage, 82.4% branch coverage · ✅ no change vs. baseline

CLI Startup Time

46ms median (x64, winapp --version) · ✅ -9ms vs. baseline

Try This Build

Installs the MSIX for your architecture, replacing any previously installed build. Needs the GitHub CLI — the command offers to install it and sign you in if it is missing.

& ([scriptblock]::Create((irm https://raw.githubusercontent.com/microsoft/winappCli/main/scripts/winapp-pr.ps1))) 732
Switching between builds often?

Put the tool on your PATH once:

& ([scriptblock]::Create((irm https://raw.githubusercontent.com/microsoft/winappCli/main/scripts/winapp-pr.ps1))) -AddToPath

Then this build is just:

winapp-pr 732

Run winapp-pr with no arguments to pick from a list of open PRs.


Updated 2026-08-12 00:07:06 UTC · commit fd4a7a4 · workflow run

Two follow-ups from review.

Only IOException is caught now. UnauthorizedAccessException is a permissions or
path failure, not the transient sharing violation, and swallowing it turned an
actionable error into a misleading 15 second "was not written within" timeout.

The race is now reproduced deterministically instead of by timing luck. The
original test only failed when a poll landed inside PowerShell's write window --
a sub-millisecond target against a 20ms poll -- which is why it survived 400
consecutive local runs while still failing on loaded CI agents. Holding the file
the way Set-Content does fails the old implementation in 174ms with the exact CI
exception, and passes with the retry.

Measurements behind the approach, against a real Set-Content writer:

  reader using File.ReadAllText            10845 sharing violations
  reader using ReadWrite|Delete share      10845 sharing violations
  writer publishing by atomic rename         503 sharing violations

Set-Content holds the destination exclusively, so no FileShare mode on the
reader can open it during the window, and publishing by rename only narrows the
window rather than closing it. Retrying is therefore the fix, not a workaround.

Also covers the two neighbouring cases: a file that never appears must still
time out, and an empty file must be polled past by the parse guard rather than
treated as an error.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: a209763e-7185-4986-bf0b-98e52c06b4be
@azchohfi
Alexandre Zollinger Chohfi (azchohfi) marked this pull request as ready for review August 11, 2026 23:48
@nmetulev
Nikola Metulev (nmetulev) merged commit bbbbe6e into main Aug 12, 2026
30 checks passed
@nmetulev
Nikola Metulev (nmetulev) deleted the azchohfi-fix-treekill-pidfile-race branch August 12, 2026 00:49
Alexandre Zollinger Chohfi (azchohfi) added a commit that referenced this pull request Aug 12, 2026
WaitForPidFile_EmptyThenPopulated_WaitsForAParsableValue failed on CI with a 15
second TimeoutException. Both tests added in #732 populated or released the file
from a Task.Run continuation and then waited on the helper, so each depended on
the thread pool scheduling that continuation promptly. On a loaded agent running
four test workers that is not guaranteed -- reintroducing, in the tests meant to
remove flakiness, exactly the kind of timing dependence they were fixing.

Neither test needs concurrency to prove its point:

- The sharing-violation test now asserts the exception TYPE with nothing ever
  releasing the handle. The old code threw IOException on the first poll; the
  retry swallows it and runs out the clock, so TimeoutException is the signal.
  Verified it still fails in 72ms when the retry is removed.
- The empty-file test asserts that a permanently empty file times out, which is
  what the parse guard is for. The populated path is already covered by the
  tree-kill test end to end.

Both now run in well under a second and cannot be perturbed by machine load.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: a209763e-7185-4986-bf0b-98e52c06b4be
Nikola Metulev (nmetulev) pushed a commit that referenced this pull request Aug 12, 2026
## Problem

`WaitForPidFile_EmptyThenPopulated_WaitsForAParsableValue` — added by me
in #732 and now on `main` — failed on CI:

```
System.TimeoutException: The descendant PID file '...empty_....pid'
was not written within 00:00:15.
```

Both tests #732 added did the same thing: populate (or release) the file
from a `Task.Run` continuation, then `await` the helper. That makes each
one depend on the thread pool scheduling that continuation promptly. On
a loaded agent running 4 test workers, it isn't.

Which is the irony worth naming: these are the tests meant to *remove*
flakiness from this file, and they reintroduced exactly the kind of
timing dependence they were fixing. A 150 ms delay under a 15 s budget
looks generous right up until the pool is saturated.

## Fix

Neither test needs concurrency to prove its point.

**Sharing violation** — assert the exception *type*, with nothing ever
releasing the handle:

```csharp
using var exclusive = new FileStream(pidFile, FileMode.Open, FileAccess.Write, FileShare.None);

await Assert.ThrowsAsync<TimeoutException>(
    async () => await WaitForPidFileAsync(pidFile, TimeSpan.FromMilliseconds(300), ct));
```

The old code threw `IOException` on the very first poll; the retry
swallows it and runs out the clock. `TimeoutException` *is* the signal
that the retry works — no second thread required.

**Empty file** — assert that a permanently empty file times out, which
is precisely what the parse guard is for. The populated path is already
covered end-to-end by the tree-kill test.

## Validation

- 3/3 pass, both rewritten tests now finishing in well under a second
instead of 15.
- Regression coverage intact: with the `catch (IOException)` removed,
`WaitForPidFile_WriterHoldsFileExclusively_RetriesInsteadOfThrowing`
still fails in **72 ms**.
- No production code touched — `DotNetService` is unchanged; this is
tests only.

This unblocks `build-and-package`, which is currently red on `main` and
on #724.

---------

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: azchohfi <527713+azchohfi@users.noreply.github.com>
Copilot-Session: a209763e-7185-4986-bf0b-98e52c06b4be
Nikola Metulev (nmetulev) pushed a commit that referenced this pull request Aug 14, 2026
## Problem

The last ten `Build and Package` failures came from **two independent
flaky tests**, not one. On Aug 13 the workflow failed 13 of 33 runs
(~39%).

| Test | Runs | Failure |
|---|---|---|
|
`DotNetServiceTests.RunDotnetProcessAsync_Cancellation_KillsProcessTree`
| 6/10 | `TimeoutException: descendant PID file was not written within
00:00:15` |
|
`CrashDumpServiceWorkflowTests.AnalyzeDumpAsync_RealManagedDump_RunsClrMdManagedEnumeration`
| 5/10 | `StringAssert.Contains` — analysis log lacks `CLR Version` |

### 1. Tree-kill — #732 fixed a real bug, just not this one

#732 and #737 fixed a **sharing-violation `IOException`** in
`WaitForPidFileAsync`, and that signature is gone. The current failure
is a different mode. It already appeared on Aug 12 at 17:39, *before*
#737 merged, and 4 of the 6 failures are on commits that **contain** the
fix — including one on `main`. Issue #729 is still open.

Root cause, reproduced directly: `Start-Process` failure is a
**non-terminating** error, so the script ran on to `Set-Content` with
`$p` still null and published a **0-byte file**:

```
File created: YES; length=0 bytes; content=<>; int.TryParse=False
```

`int.TryParse` silently skipped it and the reader polled the full 15s.
The message "was not written" was actively misleading — the file *was*
written, just empty. The child's stderr was redirected and discarded, so
the real error never reached CI. For scale, that script publishes in
**373 ms** on an unloaded machine, against a 15 s budget.

### 2. Managed dump — never fixed at all

`TestProcessDump.TryCreateManagedDump` dumped a PowerShell child after a
blind `Thread.Sleep(1500)`. Under load the child was still in **native**
startup — the CI dumps show only `hostfxr`/`hostpolicy` loading coreclr
— so ClrMD found no runtime. No commit had ever touched this.

## Fixes

**Tree-kill**
- `$ErrorActionPreference = 'Stop'` plus an explicit null guard, so a
failed child start aborts *before* `Set-Content` rather than publishing
an empty file
- `WaitForPidFileAsync` now watches the launcher task and fails fast
with the root's exit code and stderr instead of waiting out the clock
- the timeout message distinguishes never-created, unreadable, and
created-but-unparsable

**Managed dump**
- the child allocates, forces a collection, and only then signals
readiness, so the dump is taken with the runtime up **and** its heap
walkable

## On the second fix, specifically

Signalling readiness alone was **not** enough, and this is worth naming
because the obvious fix is wrong. A token emitted from the first line of
managed code proves the CLR started but not that ClrMD can walk it —
that version made the dump test fail or go inconclusive on a machine
where the old sleep passed 6/6. Warming the heap before signalling is
what actually fixes it:

| Version | Idle | Under 100% CPU load |
|---|---|---|
| Original (`Thread.Sleep(1500)`) | 6/6 pass | 6/6 pass |
| Token only | **3/6 pass** | — |
| Token after heap warm-up | 8/8 pass | 6/6 pass |

## Verification

- Both regression guards were checked against the **original** code, not
just the new code.
`TreeKillScript_WhenTheChildCannotStart_PublishesNoPidFileAtAll` fails
with the original script (`Assert.IsFalse failed ...
File.Exists(pidFile)`) and passes with the hardened one.
- The script builder is shared between the tree-kill test and its
regression test, so the script proven not to publish an empty file is
the one actually run — the two cannot drift.
- Full suite: **4,572 passed / 4,581**. The 4 failures are `winapp init`
E2E tests, confirmed pre-existing by re-running them with these changes
stashed — they fail on `Failed to get version for
Microsoft.WindowsAppSDK`, the documented corp-machine nuget.org
restriction.

Local load testing could not reproduce CI's 4-core I/O-bound conditions,
so the load column above is weaker evidence than the idle column. The
structural argument stands on its own: the old code bounded CLR startup
with a fixed 1.5 s guess, and CI captured dumps proving that guess was
exceeded.

Closes #729

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: c8e8ead7-3a65-4148-b196-a4c217fde912
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.

3 participants