fix(logs): skip log collection in Asset Import Workers, allow shared-handle Clear (#855) - #967
Conversation
…handle Clear (IvanMurzak#855) Console/ClearLogs failed with "The process cannot access the file '<project>/Temp/mcp-server/ai-editor-logs.txt' because it is being used by another process." The other process is a Unity Asset Import Worker. Workers are headless Editor instances launched with the same -projectPath as the main Editor, so each one runs [InitializeOnLoad] Startup, builds its own BufferedFileLogStorage, and resolves the identical log path. Their write handles then live as long as they do. Two changes: 1. AddUnityLogCollectorIfNeeded returns early in a worker. All seven call sites funnel through it, so one guard covers them. Workers serve no MCP client, so their import chatter was only noise in the cache anyway. 2. The log FileStream now opens with FileShare.Delete. Windows refuses DeleteFile while any open handle omits FILE_SHARE_DELETE, so without this any second holder wedges Clear() regardless of where it came from. Adds detection tests for the -name parsing and a fixture that pins Clear() against a second storage on the same file.
| => IsAssetImportWorker(Environment.GetCommandLineArgs()); | ||
|
|
||
| /// <summary> | ||
| /// Test-friendly overload. Matches only a <c>-name</c> / <c>--name</c> flag whose VALUE starts |
There was a problem hiding this comment.
Please explain what is going on here. Did you customize the log writing only if the project opened in the custom folder location? And it won't work for anybody else if their Unity project in a different location?
There was a problem hiding this comment.
No, nothing is keyed to a folder location, and the check never reads the project path at all. That comment was badly worded and it reads exactly the way you took it. Sorry.
What IsAssetImportWorker actually reads is the -name argument Unity itself passes when it spawns a worker:
Unity.exe -adb2 -batchMode -noUpm -name AssetImportWorker0
-projectPath <wherever that user's project happens to be> ...
The loop walks the args for a -name / --name flag and tests whether its value starts with AssetImportWorker. A main Editor is launched without a -name flag, so it returns false no matter where the project lives. No path is inspected or compared, and there is no hardcoded location in the function.
The AssetImportWorkerRepro folder in that comment was an example of a false positive the code avoids, not one it depends on. If the check were a substring scan of the whole command line, -projectPath would match too, and anyone whose project folder happened to contain that word would silently lose log collection in their main Editor. IsAssetImportWorker_FalseWhenOnlyThePathContainsTheWord is the test pinning that.
Rewritten in 9571844 to lead with what is actually read and to state outright that project location plays no part.
There was a problem hiding this comment.
Could you please provide a reference to the documentation about this "Unity worker" that Unity spawns with custom arguments?
On the first view it looks like an AI hallucination or some local context to a personal project.
I just want to be sure about this information, because this is the core proposition of this PR and I never heard about this workers processes earlier.
There was a problem hiding this comment.
Fair challenge. The short version: the process type is documented, the argument I keyed on is not.
Import workers are a documented Unity feature
Unity Manual, "Parallel Import", present in 2022.3 and every version since:
The Parallel Import setting uses additional headless instances of the Editor, called import workers, to import assets in parallel
- 2022.3: https://docs.unity3d.com/2022.3/Documentation/Manual/ParallelImport.html
- 6000.3: https://docs.unity3d.com/6000.3/Documentation/Manual/ParallelImport.html
That same page documents the Project Settings controlling them, and one of those settings explains why #855 reads as a race:
Standby Import Worker Count: The minimum number of worker processes to keep, even if they're idle.
Unity keeps workers alive after the import finishes, deliberately. So their file handles outlive anything the user can see happening, which is exactly why the failure looks intermittent rather than tracking a visible state.
Two things you can confirm on your own machine in under a minute, without taking my word for any of it:
- Project Settings > Editor > Asset Pipeline shows
Desired Import Worker CountandStandby Import Worker Count. <project>/Logs/AssetImportWorker0.logexists in any project that has imported anything. That is the worker's own Editor log, written via its-logFileargument.
Unity ships a public API for the exact check I hand-rolled
AssetDatabase.IsAssetImportWorkerProcess()
Determines whether the current process is an Asset Import Worker process.
https://docs.unity3d.com/ScriptReference/AssetDatabase.IsAssetImportWorkerProcess.html
It is public extern static in Unity's own C# reference source at 2022.3, which is this package's "unity" floor, and it is still there at 6000.3:
- 2022.3: https://github.com/Unity-Technologies/UnityCsReference/blob/a322ce5f78a82cf7ea211857a45338136ed7a22f/Modules/AssetDatabase/Editor/ScriptBindings/AssetDatabase.bindings.cs#L912
- 6000.3: https://github.com/Unity-Technologies/UnityCsReference/blob/efd899a3b1ca2d07ca55f021ae65236c92a101f9/Modules/AssetDatabase/Editor/ScriptBindings/AssetDatabase.bindings.cs#L1106
Unity's own editor code guards on it for the same reason this PR does, skipping work that belongs only to the main process. QuickSearch, at 2022.3:
Where that leaves this PR
You were right to push, and here is the honest part: the -name AssetImportWorker0 argument itself is not documented anywhere I can find. I read it off the live processes on my machine. The concept is official, that particular spelling is not, and I should have looked for the API before writing a parser for an undocumented argument.
So I would rather this used AssetDatabase.IsAssetImportWorkerProcess(). It deletes the arg-parsing function outright, and the false-positive worry goes with it, because no command line is inspected at all. Concretely:
EnvironmentUtils.IsAssetImportWorker()becomes a#if UNITY_EDITORcall to the API, returningfalsein player builds, where workers cannot exist. The guard is needed becauseEnvironmentUtilslives in the Runtime assembly.- The two arg-parsing tests go away, since there is no pure function left to test.
- The
FileShare.Deletehalf inFileLogStorageis untouched by this and stays as is.
Since you have already approved, that swap is your call rather than mine: I can push it onto this branch, or leave this as it stands and send it as a follow-up. Either is fine by me.
There was a problem hiding this comment.
Thanks for clarifying. I see it now. And it is a good discovery, at least to me :)
…s and say plainly what the check reads Review feedback on IvanMurzak#967. The test fixtures carried my own drive letters (D:/UnityEditor/6000.3.21f1, D:/Coding/Game) — the only D:/ strings in the whole test tree. They were inert literals fed to a pure function, but nobody should have to verify that to trust them. They now follow the convention the rest of the suite already uses (/home/user/my-game, as in ProjectIdentityGoldenVectorTests). The doc comment on IsAssetImportWorker read as though a folder name were being special-cased. It was describing a false positive the code AVOIDS, not one it relies on. Rewritten to lead with what is actually read — Unity's own -name argument — and to state that no path is inspected, compared, or hardcoded, so project location is irrelevant on any machine. No behaviour change: same detection, same six assertions, all still passing.
|
I have a temporary issue with the test runners. Need to wait for a solution, then I will run the tests to verify that everything is green. |
Accuracy corrections to the fork-PR section, from two report-only review helpers plus this pass's own verification against the GitHub API. Documentation only; no behavioural change. docs/claude/ci-unity-license.md - #543 is CLOSED (state_reason=completed, 2026-03-15), so "tracked in issue #543 and PR #971" sent a reader to a six-month-dead issue. PR #971 is the live one. - The run-33758511822 table row said "this same workflow", whose nearest antecedent is row 1's test_pull_request.yml. The run actually used test_pull_request_manual.yml (API: path=.github/workflows/ test_pull_request_manual.yml, head_branch=ci/p0-fork-license-repro). Named it. - "starts and completes in the same second" is falsified by the section's own cited runs: 32992648441's 6000.3.1f1-editmode/windows-mono leg is 04:58:48Z -> 04:58:49Z, and on the repro run 33758511822 one of the two legs is 13:01:37Z -> 13:01:38Z. Restated as a bound ("within a second"). - "the whole job is 1-3 minutes" understated the measured spread; job wall clocks on 32992648441 run 1m23s - 3m14s. Now 1-4 minutes. - The bad-.ulf ordering claim ("pulls the editor image first, then fails inside the container") was a hypothesis in declarative register - no bad-.ulf run is on record. Kept the diagnostic, marked it unverified, and grounded the "minutes" half on run 33757559794, whose licensed step takes 9-13 min per leg. - Duration does not discriminate a fork PR from a same-repo run whose secret was deleted: UNITY_LICENSE is required:false, so both deliver an empty string and fail identically. Points at the head-repository check as the discriminator. - "every run ... was red" was wrong for 4 of 7. All seven runs in the window are forks and none went green, but they are 3 failure / 2 cancelled / 2 action_required. Corrected, and documented action_required (GitHub's first-time-contributor approval gate) as a second fork-PR shape with a different signature - conclusion is null and nothing ran. - refs/pull/<n>/merge only exists while GitHub can compute a clean test-merge, neither ref is fetched by default, and workflow_dispatch takes only a branch or tag - so --ref refs/pull/<n>/head is rejected. That is what makes the deferral to #971 legible rather than unexplained. - Gave "all 12 legs" its derivation (6 caller jobs x the 2-way platform matrix) so it self-corrects if the three commented-out playmode jobs are ever enabled. - The pre-existing refresh procedure told a reader with an invalid-license run to re-issue the .ulf, which is the exact trap this section exists to prevent; added a one-clause back-pointer. .github/workflows/test_unity_plugin.yml - The comment blamed "secrets: inherit", a construct this file does not contain - it is the caller's. Attributed it to test_pull_request.yml. NOT changed, after verification: "the same commit 91e2472 the fork PRs branched from". One helper reported this as unverifiable and recommended weakening it. It is exactly right - PRs #922/#967/#968 all have base.sha=91e2472a, and `git merge-base --is-ancestor 91e2472 refs/pull/<n>/head` holds for all three with the merge-base being 91e2472 itself. The claim stands as written. Gates: Suite 4 (CI-surface) green - check_nuget_gate.py exit 0 ("NuGet gate OK: 15 pins, generation UNITY_MCP_DEPS_3, propagation consistent"), workflow YAML re-parses with all three secrets keys and three inputs intact. Suites 1-3 not applicable (nothing under Unity-MCP-Plugin/**, nothing under cli/); the substantive verification of a CI-docs change is the PR's own CI run. scan-pipeline-leaks.py: 0 hits over 108 added lines, controls 104/104. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Bac1LKpVobv1i1FNGCRNvM
Fixes #855.
The other process is an Asset Import Worker
Console/ClearLogsthrowsThe process cannot access the file '<project>/Temp/mcp-server/ai-editor-logs.txt' because it is being used by another process.I asked Windows who the holder was, via the Restart Manager API (
RmRegisterResources/RmGetList) on that exact path:Their command lines:
Asset Import Workers are headless Editor processes on the same
-projectPath. They load the Editor assemblies, so[InitializeOnLoad] Startupruns in each of them, callsAddUnityLogCollectorIfNeeded(() => new BufferedFileLogStorage()), and resolvesApplication.dataPathto the same folder as the main Editor. Every worker therefore opens the sameai-editor-logs.txtand holds a write handle for its whole lifetime.Clear()disposes only the calling process's stream, then callsFile.Delete. Windows refusesDeleteFilewhile any surviving handle omitsFILE_SHARE_DELETE, andCreateWriteStreampassesFileShare.ReadWrite. So the delete fails.Not a race
The issue title says race condition. On my machine it is deterministic: 5 of 5 calls failed on a freshly opened, idle Editor. It looks intermittent because it tracks worker lifetime, not timing. Workers persist after an import finishes, so:
Console/ClearLogsKilling both workers and calling the tool again is the whole repro loop:
Before that, same Editor, same session:
isError: trueon every call.The mechanism in isolation, same message byte for byte:
What this changes
1.
AddUnityLogCollectorIfNeededreturns early in a worker.All seven call sites (
Startup,Startup.Editor×3,MainWindowEditor,McpListWindowBase,AssistedReauthService) funnel through that one method, so a single guard covers them. This is the root-cause half: a worker serves no MCP client, so its import chatter was only diluting the cache thatconsole-get-logsreads.I used
-name AssetImportWorker*rather thanApplication.isBatchMode, deliberately.isBatchModeis also true for CI and for anyone running the Editor headless on purpose, and I did not want to silently disable log collection for them. And it matches on the value of-name, not on a substring of the whole command line, so a project stored under a folder calledAssetImportWorkerReprodoes not disable collection in its main Editor. There is a test for exactly that.2. The log
FileStreamopens withFileShare.Delete.Defence in depth for the general case. Asset Import Workers are the common second holder, not the only possible one, and any of them wedges
Clear()today. With the flag on both the write and read opens,Clear()survives a foreign handle whatever opened it.Two things I noticed but did not change
The uniquifying retry loop in
CreateWriteStreamis unreachable. It renames to-2,-3, up to 1000 attempts, but only when the open throws.FileShare.ReadWritelets the second writer straight in, so the loop has never fired. Its intent (one file per process) would fix this class of problem at the source, but tightening the share mode to force it is a design call that belongs to you, not to a bug-fix PR.SecondStorage_SharesTheSamePathRatherThanForkingANewFilepins the current behaviour so the change is visible if you ever make it.Concurrent appenders can overwrite each other.
FileMode.Appendin .NET seeks to end at open, not per write, so two processes that opened the file when it was empty both write from position 0. Garbled lines are then silently dropped by thecatchinDeserializeLogEntry. The guard in change 1 removes the usual source of concurrency, so I left this alone.Verification
Ran on Unity 6000.3.21f1, Windows 11, plugin 0.90.0, server 9.2.5:
console-clear-logssucceeds →console-get-logsreturns[]→ the 1.9 MB file is goneFileSharemechanism reproduced standalone, both directionsNot run locally: the new tests. The package's test assembly is not compiled in a consumer project without a
testablesentry, and I did not want to reshape a working project to get there. They are written to fail before this patch by construction (Clear_SucceedsWhileASecondStorageHoldsTheSameFilethrows the reportedIOExceptionwithout change 2), but CI is the first place they actually execute.