Skip to content

A file can still be locked after the process that owned it has exited

Not a game finding. This is a Windows / process-lifetime behaviour observed while driving the game, and nothing on this page depends on the Stationeers version. The frontmatter carries a game version because the schema requires one; read it as "the version in the tree when this was written", not as a claim that the behaviour changes with the game.

Anything that stops a process and then deletes that process's files can fail on a sharing violation even though it correctly waited for the process to go away. The failure is transient and clears on its own. Treating it as terminal costs whatever the caller was in the middle of.

What was measured

2026-08-16, on a live playtest suite. The test rig's state reset failed deleting

<instance>\logs\unity-20260816-020316.log

with a sharing violation. The handle belonged to the game instance the PREVIOUS check had stopped: the rig had already observed that instance exit, and the step that stopped it had completed. The condition was transient and self-healing, and a later attempt on the same file succeeded.

That is the whole of what was observed. Specifically, the measurement is: a file remained locked after the process that owned it had exited, as the rig observed exit. It is not a measurement of why.

Cost, for calibration on whether this is worth guarding against: that one sharing violation ended three checks in the run. The check that hit it, plus the two behind it, which found the rig still locked because the failure escaped past the release (a separate rig bug, since fixed, recorded in TestRig/RESEARCH.md).

The mechanism is not established, and the fix does not need it to be

The obvious reading is that Windows releases a process's file handles asynchronously during teardown, so the process object can be gone while its files are still closing. That reading is plausible and it is what the rig's own code comments assert. This page does not assert it, and the reason to hold the line here is that at least two other explanations produce exactly the same observation and neither was excluded (see Open questions).

What matters in practice is that the remedy is identical under every candidate explanation:

  • If teardown is still releasing handles, waiting works.
  • If the process had not truly finished exiting when the observer judged it had, waiting works.
  • If an antivirus scanner, the Windows Search indexer or a file-sync client had the file open, waiting works.

So build the retry, and do not build anything that depends on knowing which one it was. A guard that keys off "the process has exited, therefore its files are closed" is wrong under the first two readings and irrelevant under the third.

Corollary worth stating on its own: waiting for exit is not waiting for the files to close. A teardown that polls a process-liveness predicate has satisfied a different condition than the one a subsequent delete needs.

The mitigation shape: two retry budgets, at different layers

Two budgets, two causes, and the fast one stays fast. The transferable part is the layering, not the numbers.

Inner, per call: a short backoff for a scanner touching a file for a few milliseconds. SystemFileSystem.DeleteFile makes ten attempts, sleeping 5 * attempt milliseconds between them and swallowing only IOException / UnauthorizedAccessException:

public void DeleteFile(string path)
{
    Exception? last = null;
    for (var attempt = 1; attempt <= DeleteAttempts; attempt++)
    {
        try
        {
            if (!File.Exists(path)) return;
            ClearReadOnly(path);
            File.Delete(path);
            return;
        }
        catch (DirectoryNotFoundException)
        {
            return;
        }
        catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
        {
            last = ex;
            if (attempt < DeleteAttempts) Thread.Sleep(5 * attempt);
        }
    }

    throw last!;
}

DeleteAttempts is 10, and the sleep is skipped on the final attempt, so the sleeps run 5+10+15+20+25+30+35+40+45 ms across nine gaps: 225 ms of waiting in total. (Both ResetExecutor.cs:99-103 and TestRig/RESEARCH.md describe this as "a 275 ms budget". 275 is 5 * (1..10), which counts a tenth sleep the loop never performs. The mechanism those documents describe is right and the arithmetic is 50 ms high.)

Raising this inner budget to seconds would slow every failing delete in the caller and would still be per file.

Outer, per run: a sweep for a handle that outlived its process. The reset collects every action that failed with an IO-shaped error and retries all of them together, waiting

public static readonly IReadOnlyList<int> TransientRetryDelaysMs = [250, 500, 1000, 2000, 4000];

between sweeps. Per RUN rather than per action is the whole point: twenty held files cost the same wall clock as one, 7.75 s of added delay at worst, whatever the plan looks like. A per-action retry with this budget would be minutes on a plan that is genuinely stuck, and a cleanup that looks like a hang is its own failure. The other actions running in between are free waiting time for the handle as well.

Three constraints keep the sweep from turning into a way to lose information:

  • Only IO-shaped failures are swept. A refusal the tool has already decided on (a setting that vanished between plan and execute, a redirect that cannot be re-applied) is not a race, and no amount of waiting changes it. Retrying it spends the whole budget to arrive at the same answer.
  • The refusal is not weakened. An action still failing when the budget runs out is a failure: the run throws, the dirty marker stays set, and nothing is silently skipped. A file that could not be cleared is exactly how stale state poisons a later assertion.
  • The recovery is reported, not swallowed. When a sweep clears actions that had failed, the run says so at warning level. A handle outliving the process that owned it is worth knowing about even when it costs nothing this time.

Rules for anything that deletes after stopping a process

  1. Retry the delete. Observing the process exit does not license a single attempt.
  2. Put the long budget at the batch level and the short one at the call level.
  3. Retry only IO-shaped errors. A decision is not a race.
  4. Do not downgrade the final failure. Transient and terminal need different handling, not a shared shrug.
  5. Classify the error from Exception.HResult, never from Exception.Message. See UseSystemResourceKeys; under that publish setting the message for a sharing violation is the bare string IO_SharingViolation_File, <path>.

The sibling case, where the writer is a live process rather than an exited one, is on FileSystemWatcherMainThread: a FileSystemWatcher.Created event can fire while the writer still holds the file open, and the remedy there is the same shape (FileShare.ReadWrite plus a bounded retry on IOException).

Verification history

  • 2026-08-17, 0.2.6428.27798: page created from TestRig/RESEARCH.md:472-498, which records the 2026-08-16 measurement. Retry constants, loop structure and refusal behaviour re-read from ResetExecutor.cs and SystemFileSystem.cs in the tree at 0803ed8e rather than taken from the prose. The asynchronous-handle-release mechanism is deliberately NOT carried over as a verified claim: the source documents state it in the imperative, the observation underneath it does not distinguish it from two other explanations, and this page records the observation and files the mechanism under Open questions. One arithmetic correction against the source: the inner delete budget is 225 ms of sleep, not the 275 ms both source documents state.

Open questions

  • Which explanation is correct. All three fit the single observation and none was excluded: (a) Windows was still releasing the exited process's handles; (b) the process had not fully exited at the moment the rig judged it had; © a third party (antivirus, the Windows Search indexer, a file-sync client) held the handle. Reading (b) is not far-fetched here: the same rig found separately that a process object stays enumerable after exit for as long as anybody holds a handle to it, and that Process.GetProcessesByName therefore lists processes that have already gone (TestRig/RESEARCH.md:449-470), so "the observer's notion of exited" is a known soft spot.
  • How to settle it. Handle.exe or the Restart Manager API (RmGetList) names the owning process for a locked file. Capturing that at the moment of failure, rather than after the condition has self-healed, is the discriminator. That needs the probe wired into the failure path before the next occurrence, because the window closes on its own within seconds.
  • How long the window actually is. The measured case cleared within the sweep budget, but no instrumented figure exists: the run does not record which sweep cleared it. Recording the sweep index on recovery would turn a future occurrence into a distribution.