dotnet / dotnet/sdk

Subject: MSBuild's Indiscriminate Retry on Permanent File Locks Causes Unnecessary Build Time Waste

Open
#55,933 0 comments 0 reactions 0 assignees View on GitHub
untriaged
Dominant language
C#
Stars
3.2k
Forks
1.3k
PR merge metrics
PR metrics pending

Description

### Describe the bug

Dear .NET / MSBuild Team,

I am writing to report a long-standing usability flaw in MSBuild's file-copy retry mechanism, specifically regarding the behavior of warning **MSB3026**.

**Problem Statement**

When `dotnet publish` attempts to overwrite an executable file that is currently held by a running process, MSBuild's `Copy` task falls back to its retry loop. By default that loop performs **10 retries at 1-second intervals** (`Copy.Retries = 10`, `RetryDelayMilliseconds = 1000`, wired into the common targets through the `CopyRetryCount` / `CopyRetryDelayMilliseconds` properties). During this period the build does not progress, and the copy operation never succeeds — unless the user manually terminates the locking process within the retry window.

**Crucially, if the user does not intervene, the build eventually fails** after roughly 10 seconds of futile waiting. To be precise, this is not a "timeout": once the retry budget is exhausted, MSBuild logs error **MSB3027** ("Exceeded retry count of 10") followed by **MSB3021** ("Unable to copy"), and the copy is reported as failed. The build log does identify the locking process by name and PID (via the Windows Restart Manager / `LockCheck`), but it offers **no actionable resolution** — in particular, no hint that terminating the process would unblock the build.

> *Example log snippet:*
> ```
> warning MSB3026: 无法将“...exe”复制到“...exe”。1000 毫秒后将开始第 1 次重试。
> ... (repeated for 10 attempts) ...
> error MSB3027: ... Exceeded retry count of 10. Failed. The file is locked by: MusketLegend_Server (PID: 28068).
> ```
> The file is locked by `MusketLegend_Server (PID: 28068)`.

In my own testing, the build succeeded **only after I manually killed the locking process during the retry loop**. Without that intervention, the build exhausted its retries and failed.

**Why This Is a Design Flaw**

The current retry logic assumes every file-lock condition is a transient I/O glitch. That assumption is valid for short-lived locks caused by antivirus scanning, search indexing, or network latency. It is fundamentally incorrect for persistent locks that will **never** resolve unless the owning process is terminated — most notably a running executable, which the OS loader holds in a read-only sharing mode (the file is readable, but not writable or deletable). On overwrite this surfaces as `ERROR_SHARING_VIOLATION`, which is exactly the condition the retry loop was tuned to handle — and precisely the condition retrying cannot fix.

This indiscriminate approach is not merely inefficient — it is actively misleading:

- The build wastes ~10 seconds on futile retries and then fails with a generic "unable to copy" error, even though the build system already knows exactly which process holds the file.
- The user receives no actionable guidance. The log prints the PID but never suggests terminating the process.
- A build "succeeds" only if the user happens to kill the process mid-loop — an accidental, timing-dependent outcome, not a robust tool-level solution.

To borrow a metaphor: this is equivalent to Sisyphus pushing a boulder up a hill — repetitive, futile, and entirely avoidable.

**Proposed Solution**

I respectfully suggest that MSBuild distinguish between **transient locks** (worth retrying) and **permanent process-exclusive locks** (never going to resolve), and handle them with different strategies. The classification can be done with a single probe that mirrors how `File.Copy` opens the destination file — write access with `FileShare.Read`. If the destination can be opened that way, a retry can succeed; if it cannot, no retry ever will.

| Lock Type | Detection Method | Handling Strategy |
|-----------|------------------|-------------------|
| Transient | Destination can be opened for write with `FileShare.Read` (the access the copy actually needs) | Keep the existing retry loop — it is already tunable via `CopyRetryCount` / `CopyRetryDelayMilliseconds` |
| Permanent | Destination **cannot** be opened even for write with `FileShare.Read`; a process holds it exclusively or in a read-only sharing mode (running executable, `FileShare.None`) | After confirming the lock persists for a short window (~300 ms), **fail immediately** with a clear error including the process name, PID, and a suggested `taskkill` command |

Notes for the maintainers, based on the current implementation (`src/Tasks/Copy.cs`, `DoCopyWithRetries`; `src/Utilities/LockCheck.cs`):

- **Only sharing violations are retried today.** On Windows, `ERROR_ACCESS_DENIED` already aborts without retrying (ACL / read-only attribute); the futile loop is specific to `ERROR_SHARING_VIOLATION` and its relatives — exactly the running-executable case reported here.
- **A confirmation window matters.** A single failed probe could be a momentary exclusive handle (e.g. a brief antivirus scan). Requiring the probe to fail a few consecutive times keeps the "fail fast" path robust while still cutting the wait from ~10 seconds to well under 1 second.
- **A new error code is required.** The `Copy` message bucket (MSB3021–MSB3030) is already full, so the new error should take the next code, e.g. **MSB3031**. (MSB3026 is the existing *warning* emitted per retry and should not be reused for the final error.)
- **Gate the change behind a Change Wave**, per the repo's convention for behavior changes (compare the `ERROR_ACCESS_DENIED` retry change gated by `ChangeWaves.Wave18_7`), so it can ship enabled-by-default with an opt-out via `MSBUILDDISABLEFEATURESFROMVERSION`.
- **The behavior is Windows-centric.** `FileShare` semantics are enforced cross-process on Windows; on Unix the probe will generally report "not exclusive", making the change a safe no-op there — consistent with `LockCheck`, which already only reports on Windows.

**Expected User Experience After Optimization**

```
error MSB3031: Could not copy 'MusketLegend_Server.exe' to '...\publish\MusketLegend_Server.exe' because the destination is exclusively locked by another process, and the lock will not be released while that process is running. The copy was aborted instead of being retried.
The file is locked by: MusketLegend_Server (PID: 28068).
Please terminate the process and retry:
taskkill /PID 28068 /F
Build FAILED.
```

This eliminates the ~10-second waiting period and provides immediate, actionable feedback — saving developer time and reducing frustration.

**Conclusion**

This is not a complex engineering problem. The fix is small and localized to the `Copy` task's retry logic, and the improvement to developer productivity is substantial. I kindly urge the team to prioritize this refinement in an upcoming SDK release.

Thank you for your attention. I look forward to seeing this quality-of-life improvement in a future version of MSBuild.

Sincerely,
A .NET Developer

---

### Steps to reproduce

1. Build and start a .NET application that produces an executable file (e.g., `MusketLegend_Server.exe`).
2. Keep the application running after startup, so the executable file remains open in the publish directory in a way that blocks overwrite.
3. Open a terminal and navigate to the project directory.
4. Execute the following command:
```
dotnet publish -c Release -r win-x64
```
5. Observe the build output as MSBuild attempts to copy the new executable to the `publish` directory.
**Note:** The build will hang in retries for ~10 seconds. If you do **not** manually kill the locking process, the build will exhaust its retries and fail. If you kill it during the retry loop, the build may complete successfully — but that success depends on timing and manual intervention.

**Prerequisites**
- The target executable file must exist in the output directory prior to publishing.
- The running process must keep the destination file open in a way that blocks overwrite. For a running executable this is the OS loader's read-only sharing mode (the file is readable but not writable/deletable) — not, as sometimes assumed, `FileShare.None`.
- The build configuration must be set to overwrite the existing file in the publish directory.

---

### Expected behavior

When MSBuild detects that the destination file is exclusively locked by a running process, the build should:

1. **Immediately identify the lock type** as permanent (process-exclusive) rather than transient.
2. **Abort the copy operation instantly** without entering a retry loop.
3. **Output a clear, actionable error message** that includes:
- The full path of the file that cannot be accessed.
- The name and PID of the process holding the lock.
- A precise command to terminate the blocking process (e.g., `taskkill /PID /F`).
4. **Fail the build** with a non-zero exit code, clearly indicating that the publish operation did not succeed.

The total time from the copy attempt to the build failure should be under **1 second**. No retry attempts should be made once a permanent lock is identified. The user should never be left waiting for a condition that cannot resolve itself.

---

### Actual behavior

When MSBuild encounters a destination file that is exclusively locked by a running process, the build exhibits the following behavior:

1. MSBuild **does not distinguish** between transient locks and permanent process-exclusive locks; it retries all sharing violations (`ERROR_SHARING_VIOLATION`).
2. With the defaults, the build makes **11 attempts with 10 one-second delays** (10 retries), taking **~10 seconds**.
3. During each retry, the same warning (`MSB3026`) is emitted, repeatedly stating that the file is being used by another process and identifying the locking PID.
4. If the user does **not** manually terminate the locking process during the retry window, the retries are exhausted: MSBuild logs `MSB3027` ("Exceeded retry count") and `MSB3021` ("Unable to copy"), and the build fails.
5. If the user happens to kill the process mid-retry (as I did in my testing), a subsequent retry then succeeds and the build reports **"success"** — but this is a fragile, timing-dependent workaround, not a robust tool-level fix.
6. In either case, the developer waits ~10 seconds and receives no actionable guidance — only the locking PID, never a resolution command.

**Actual Output Snippet**

```
warning MSB3026: 无法将“...exe”复制到“...exe”。1000 毫秒后将开始第 1 次重试。
... (repeated for 10 attempts) ...
error MSB3027: ... Exceeded retry count of 10. Failed. The file is locked by: MusketLegend_Server (PID: 28068).
error MSB3021: ... Unable to copy ...
```

**Key Issues**
- The build outcome depends on manual timing — not on reliable build logic.
- The user is informed of the PID but not given a resolution command.
- The retry loop is futile, as the lock will never release until the user terminates the owning process.

---

### Is this a regression?

No, this is not a regression. This behavior has been present in MSBuild for as long as I can recall, spanning multiple .NET SDK versions.

**Historical Context**

| SDK Version | Behavior | Status |
|-------------|----------|--------|
| .NET Core 2.x | 10 retries on file lock | Affected |
| .NET Core 3.x | 10 retries on file lock | Affected |
| .NET 5 | 10 retries on file lock | Affected |
| .NET 6 | 10 retries on file lock | Affected |
| .NET 7 | 10 retries on file lock | Affected |
| .NET 8 | 10 retries on file lock | Affected |
| .NET 9 | 10 retries on file lock | Affected |
| .NET 10 (current) | 10 retries on file lock | Affected |

This is a **long‑standing design flaw** rather than a recently introduced bug. The retry mechanism was originally designed to handle transient I/O issues such as antivirus scanning or network file share latency. However, it was never refined to distinguish between transient locks and permanent process‑exclusive locks.

**Why This Matters for Triage**

Since this is not a regression, there is no need to bisect commits or investigate which PR introduced the behavior. The fix would be an **enhancement** rather than a bug fix — a deliberate refinement of the existing retry logic to incorporate lock‑type detection.

**Recommendation**

I suggest treating this as a **feature improvement** with low implementation cost and high developer experience value, rather than a critical bug that requires immediate hotfix. As with previous copy-related behavior changes, it would naturally ship behind a Change Wave.

---

### Are there any workarounds?

Yes, several workarounds exist. However, most of them are **external to MSBuild** and require manual intervention or custom scripting. None of them address the root cause within the build tool itself.

**Workaround 1: Manually Terminate the Locking Process**

This is the simplest and most reliable workaround. Before running `dotnet publish`, manually end the process that is holding the lock.

```cmd
taskkill /PID /F
```

Or, if you know the process name:
```cmd
taskkill /F /IM MusketLegend_Server.exe
```

**Drawback:** Requires manual action every time. Cannot be automated without custom scripts. (And as noted, if you kill it mid‑retry, the build may succeed — but this is unreliable.)

---

**Workaround 2: Publish to a Different Output Directory**

Specify a new output path that does not conflict with the locked file.

```cmd
dotnet publish -o ./publish_new
```

**Drawback:** Accumulates multiple publish directories over time. The final executable is placed in a different location than usual, which may break deployment automation.

---

**Workaround 3: Custom Pre‑Publish Script in .csproj**

Add a custom MSBuild target to automatically kill the locking process before the publish operation begins.

```xml

```

**Drawback:** This is a project‑specific workaround. It assumes the process name is known and hardcoded. It also forcefully terminates the process, which may cause data loss if the server has unsaved state.

---

**Workaround 4: Use a PowerShell Wrapper Script**

Wrap the `dotnet publish` command in a PowerShell script that first checks for and terminates any running instances of the target executable.

```powershell
# publish.ps1
Get-Process -Name "MusketLegend_Server" -ErrorAction SilentlyContinue | Stop-Process -Force
dotnet publish -c Release -r win-x64
```

**Drawback:** Requires developers to remember to use the wrapper script instead of the native `dotnet publish` command. Does not help in CI/CD pipelines without explicit scripting.

---

**Workaround 5: Delete the Publish Directory Before Publishing**

Remove the entire `publish` folder before the copy attempt.

```cmd
rmdir /s /q bin\Release\net10.0\win-x64\publish
dotnet publish
```

**Drawback:** If the executable is still running, the deletion will also fail with a file‑in‑use error, so this only works if combined with Workaround 1.

---

**Workaround 6: Disable Copy Retries (Built-In, MSBuild-Side)**

The retry count and delay are first-class `Copy` task parameters, so the loop can be disabled entirely without any scripting:

```cmd
dotnet publish -c Release -r win-x64 -p:CopyRetryCount=0
```

**Drawback:** This is the closest thing to a native fix today, but it is a blunt instrument — it disables retries for *all* copy failures (including genuinely transient antivirus/indexer locks), and the resulting error message is the generic `MSB3027`/`MSB3021`, with no actionable `taskkill` hint. Relatedly, the secret environment variable `MSBUILDALWAYSRETRY=1` already exists to force retries even on `ERROR_ACCESS_DENIED`; this proposal is essentially the inverse of that knob and deserves equal treatment.

---

**Summary of Workarounds**

| Workaround | Effort | Reliability | Automation‑Friendly |
|------------|--------|-------------|---------------------|
| Manual taskkill | Low | High (if done before build) | No |
| Different output dir | Low | High | Yes |
| .csproj pre‑target | Medium | High | Yes |
| PowerShell wrapper | Medium | High | Yes |
| Delete publish dir | Low | Low | No |
| `-p:CopyRetryCount=0` | Low | High | Yes |

**Conclusion**

While workarounds exist, none of them are ideal. They either require manual intervention, introduce project‑specific hacks, impose additional process management overhead on the developer, or bluntly disable retries for all cases. A proper fix within MSBuild — distinguishing transient from permanent locks and failing fast on the latter with a `taskkill` hint — would eliminate the need for all of these and provide a seamless experience out of the box.

---

### dotnet --info output

```console
❯ dotnet --info
.NET SDK:
Version: 10.0.301
Commit: 96856fd726
Workload version: 10.0.300-manifests.8c7d7c03
MSBuild version: 18.6.4+96856fd72

运行时环境:
OS Name: Windows
OS Version: 10.0.26200
OS Platform: Windows
RID: win-x64
Base Path: C:\Program Files\dotnet\sdk\10.0.301\

已安装 .NET 工作负载:
没有要显示的已安装工作负载。
已配置为在安装新清单时使用 workload sets。
未安装任何 workload sets。运行 "dotnet workload restore" 以安装工作负载集。

Host:
Version: 10.0.9
Architecture: x64
Commit: 901ca94124

.NET SDKs installed:
10.0.301 [C:\Program Files\dotnet\sdk]

.NET runtimes installed:
Microsoft.AspNetCore.App 10.0.9 [C:\Program Files\dotnet\shared\Microsoft.AspNetCore.App]
Microsoft.NETCore.App 8.0.13 [C:\Program Files\dotnet\shared\Microsoft.NETCore.App]
Microsoft.NETCore.App 10.0.9 [C:\Program Files\dotnet\shared\Microsoft.NETCore.App]
Microsoft.WindowsDesktop.App 8.0.13 [C:\Program Files\dotnet\shared\Microsoft.WindowsDesktop.App]
Microsoft.WindowsDesktop.App 10.0.9 [C:\Program Files\dotnet\shared\Microsoft.WindowsDesktop.App]

Other architectures found:
x86 [C:\Program Files (x86)\dotnet]
registered at [HKLM\SOFTWARE\dotnet\Setup\InstalledVersions\x86\InstallLocation]

Environment variables:
Not set

global.json file:
Not found

Learn more:
https://aka.ms/dotnet/info

Download .NET:
https://aka.ms/dotnet/download
```

---

### IDE version

JetBrains Rider 2025.3.5

---

### Other details

- **Video demonstration:** https://drive.google.com/file/d/1zSSHk9NjAeaTDUWMbKrdggaFYjkLpQz1/view?usp=sharing
- **Additional context:** The behavior is consistent across all SDK versions from .NET Core 2.x through the current .NET 10 SDK. The retry loop wastes developer time and, without manual intervention, ends in a failure after the retries are exhausted. A lock-type detection that mirrors the copy's own open semantics, confirmed over a short window and reported with a `taskkill` command, would resolve this once and for all.

Contributor guide

No contributing guide indexed for this repository

Research direction

Start in src/Tasks/Copy.cs at DoCopyWithRetries and compare its sharing-violation handling with src/Utilities/LockCheck.cs; review the ChangeWaves.Wave18_7 convention for gating behavior changes. Reproduce with dotnet publish while the destination executable is running, then verify that persistent Windows locks fail in under one second with the requested lock details and guidance, while transient locks retain the existing retry behavior.

Written by the indexing model from the issue text.

Assessment

Tech stack
csharp
Domain
build-system
Issue type
Bug
Difficulty
4/5
Estimated time
3-5 days
Activity status
Active
Clarity
Mostly clear
Newbie friendliness
52/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.