Skip to content

enable out of process execution of inline tasks#11948

Merged
JanProvaznik merged 61 commits intodotnet:mainfrom
JanProvaznik:kickroslyntasks
Oct 3, 2025
Merged

enable out of process execution of inline tasks#11948
JanProvaznik merged 61 commits intodotnet:mainfrom
JanProvaznik:kickroslyntasks

Conversation

@JanProvaznik
Copy link
Member

@JanProvaznik JanProvaznik commented Jun 3, 2025

Out-of-Process Execution of Inline Tasks

Summary

Enables inline MSBuild tasks (RoslynCodeTaskFactory, CodeTaskFactory, XamlTaskFactory) to execute in TaskHost for multithreaded build support.

Issue: #11914 | PR: #11948

The Problem

Inline tasks compiled to in-memory assemblies can't run out-of-process because:

  1. In-memory assemblies have no file path (Assembly.Location is empty)
  2. TaskHost requires a physical file to load the assembly
  3. Custom references need resolution in the TaskHost process

The Solution

Compile inline tasks to temporary disk files when MSBUILDFORCEINLINETASKFACTORIESOUTOFPROC=1:

  1. Factory compiles to %TEMP%/<user>/InlineTaskTempDllSubPath/pid_<PID>/<guid>_inline_task.dll
  2. Creates .loadmanifest file with reference assembly paths
  3. TaskExecutionHost wraps task in TaskHostTask
  4. TaskHost loads assembly from disk and registers reference resolvers
  5. BuildManager cleans up temp directory on build end

Key Implementation Details

IOutOfProcTaskFactory Interface

Marker interface in src/Framework/IOutOfProcTaskFactory.cs implemented by all three built-in factories:

internal interface IOutOfProcTaskFactory
{
    string? GetAssemblyPath();  // Returns path to compiled assembly
}

Used by TaskExecutionHost to distinguish built-in factories from custom ones (which fail with error).

TaskFactoryUtilities Class

Shared utilities in src/Shared/TaskFactoryUtilities.cs:

  • GetTemporaryTaskAssemblyPath() - Creates process-specific temp path
  • LoadTaskAssembly(path) - Loads from bytes to avoid file locking
  • CreateLoadManifest(path, dirs) - Writes .loadmanifest file with reference directories
  • RegisterAssemblyResolveHandlersFromManifest(path) - TaskHost reads manifest and registers resolvers
  • CleanCurrentProcessInlineTaskDirectory() - Called in BuildManager.EndBuild()

Assembly Caching

Factories cache compiled assemblies by (task name + source code + references):

ConcurrentDictionary<FullTaskSpecification, CachedAssemblyEntry>

Where CachedAssemblyEntry stores both the Assembly and the file path. Cache validates file still exists before reuse.

Execution Flow in TaskExecutionHost

When Traits.Instance.ForceTaskFactoryOutOfProc == true:

  1. Skip if IntrinsicTaskFactory (always in-proc)
  2. Verify factory implements IOutOfProcTaskFactory or error
  3. Create task, get assembly path via GetAssemblyPath()
  4. Wrap in TaskHostTask which routes to out-of-process execution
  5. TaskHost loads assembly and registers reference resolvers from manifest

Load Manifest Format

Plain text file <assembly>.dll.loadmanifest with one directory per line for assembly resolution.

Configuration

Set MSBUILDFORCEINLINETASKFACTORIESOUTOFPROC=1 to enable.

Trait defined in src/Framework/Traits.cs:

public readonly bool ForceTaskFactoryOutOfProc = 
    Environment.GetEnvironmentVariable("MSBUILDFORCEINLINETASKFACTORIESOUTOFPROC") == "1";

Important Notes

Performance When Enabled

Adds overhead: disk I/O (~4KB/task), TaskHost process creation, duplicate assembly loading. Mitigated by assembly caching and sidecar TaskHost (when available). Typical compilation: ~2s first time, then cached.

Performance When Disabled

Zero impact - feature is opt-in, no code paths change when off.

Testing

All existing inline task tests converted to [Theory] with bool forceOutOfProc parameter, running in both modes.

Limitations

Custom TaskFactory: Not supported with out-of-process execution (build error). Workaround: disable multithreaded mode or convert to standard assembly-based task.

File Cleanup: If MSBuild crashes, temp files (~4KB/task) remain in %TEMP%/<user>/InlineTaskTempDllSubPath/pid_<PID>/. User can safely delete parent directory.

Related Issues

Copy link
Member

@AR-May AR-May left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There is possibly another alternative we may mention, that is sending the code to the task host and compiling there, but I think it is worse than doing it in the main process, even if task hosts would be persistent.

The changes imo have to be behind a feature flag - as indeed they will affect performance in order to allow compatibility with multithreaded mode.

As for user defined custom task factories, I wonder how used the feature is. We should add any necessary API changes (if they are needed) so they customers can re-write them to work with multithreaded mode. But by default, it seems they would not work with task hosts, ie would not be compatible with multithreaded mode if their tasks are not thread safe.

@rainersigwald
Copy link
Member

"send the information about the task to the host process and do the older process there" is more what I was expecting--can you elaborate a bit more on the cons of that approach?

@JanProvaznik
Copy link
Member Author

"send the information about the task to the host process and do the older process there" is more what I was expecting--can you elaborate a bit more on the cons of that approach?

  • complexity of implementing node communication about Factory initialization, the TaskHostTask wrapping was there already, once the task is in a dll it's just as if we had any other task
  • the initialization (compilation) happens during the search for the task in TaskRegistry and it would be hard to keep in sync with how it currently logs errors when it is sent to a different process
  • I think caching assemblies would need sidecar taskhost first

@AR-May
Copy link
Member

AR-May commented Jun 6, 2025

For the benefits of suggested approach, I will add that even with sidecar taskhosts, compilation of the task assembly in the worst case would be needed on each of those. And compilation overhead is quite big - around 2 seconds according to @JanProvaznik's data. Having it compiled and then location cached in the main node minimizes that overhead.

We are also nicely reusing current infrastructure - that is the minimal additional complexity mentioned by @JanProvaznik

The drawback is obvious - instead of creating a temp file for maximum mere seconds we create it for the duration of the build. And if the process is killed, the temp files would not be cleaned up - that's the main risk.

@JanProvaznik
Copy link
Member Author

JanProvaznik commented Jun 6, 2025

I'm not sure how severe the risk is, how often a nongraceful process exit happens and what would be the expectation of disk overuse when you're building on a dev machine X times per day...

a maybe silly idea: probabilistic deep cleanup

  • 1/100 before exiting look if there are any other VS/msbuild.exe/dotnet processes running, if no delete the parent directory of the process's dll temp directory (which removes all dlls left over by prior processes)

I am very open to suggestions how to manage these files another way.

data:
a trivial inline task dll is 4kb

@JanProvaznik
Copy link
Member Author

new disposal idea thx @AR-May @rainersigwald : I have to test it if the OS API really works this way and the extent to which this works on *nix

in the Factory initialization open a file handle for the compiled dll with a flag FileOptions.DeleteOnClose, save it in the factory's property so it's not GCd, the flag is supposed to mark the file for deletion on the OS (for windows)/CLR (for *nix) level. Once the process dies the file handle dies and the file is deleted. (on *nix it's best effort because if you kill -9 the process the CLR doesn't have time to do cleanup)
Nice theory...

@JanProvaznik
Copy link
Member Author

JanProvaznik commented Jun 9, 2025

new disposal idea thx Alina, Rainer: I have to test it if the OS API really works this way and the extent to which this works on *nix

in the Factory initialization open a file handle for the compiled dll with a flag FileOptions.DeleteOnClose, save it in the factory's property so it's not GCd, the flag is supposed to mark the file for deletion on the OS (for windows)/CLR (for *nix) level. Once the process dies the file handle dies and the file is deleted. (on *nix it's best effort because if you kill -9 the process the CLR doesn't have time to do cleanup) Nice theory...

not so simple, because how we load in taskhost nodes now we use Assembly.Load and the DeleteOnClose requires that the file is opened with the FILE_SHARE_DELETE which Assembly.Load does not expose api for doing that.

Possible workaround for that is detecting the "temporary dll file" situation and loading first to memory by opening with the flag and then loading from memory.

Which needs more logic to pass the path... because the location of the in memory assembly is empty again.

@JanProvaznik
Copy link
Member Author

complication: the references of the inline tasks have to be somehow loaded on the taskhost and they could be arbitrary dlls, this resolve reference logic is nontrivial LoC in each factory

@JanProvaznik
Copy link
Member Author

So the inline tasks can have references. I glossed over that initially thinking the resolution info would be in the dll so calling Assembly.Load on the dll with the task would be enough. Apparently it does not work that way, (also CodeTaskFactory and RoslynCodeTaskFactory do it differently).
So there is a bunch of logic for this reference resolution and loading that's not cross-process compatible.

brainstorming how to proceed:
1.  reconstruct the loading logic in taskhost, the resolution before compilation in main proc would place a metadata file next to the dll that is loaded by the taskhost telling it how to load the dll. Sounds nice in theory, not sure how the implementation will go...
  1b. implement it in node communication instead of a file?
2. copy the referenced dlls next to the temp dll which would lead to them being found when loading the task dll. Increases the severity of disk space leaks.
3. reconsider the decision to split the initialization and execution -> compile and execute in the same sidecar taskhost and then make sure the taskhost is reused for instances of execution to avoid recompilation. I am dreading to rearchitect this and depends on sidecar.

I am inclined to start with 1. Though I am afraid of what more roadblocks I'll find.

JanProvaznik and others added 7 commits June 10, 2025 18:41
Some tests were failing when run from Visual Studio because they were
running in a .NET 9 process launched as `TestHost.exe`, not in a `dotnet.exe`
host. That caused TaskHost launching to try to run `TestHost.exe MSBuild.dll`
which failed, as TestHost isn't a general-purpose launcher.

Add another fallback to `GetCurrentHost` to fall back to the current
shared framework's parent `dotnet.exe`, if we can find it.
@JanProvaznik
Copy link
Member Author

ready for another review after major changes @AR-May

Copy link
Member

@AR-May AR-May left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Overall looks good to me. We will need to test the behavior with exp insertion.
Better to create two, one of them should additionally set forcing the tasks out of proc by default - let's see if we notice any errors in DDRITS.

@JanProvaznik
Copy link
Member Author

JanProvaznik commented Oct 2, 2025

I'm creating 2 exp insertions:
one for validating this does not break VS when off - passed
one for validating impact on VS when the trait is enabled - expected error when loading xamltaskfactory from tasks.core.v4.0.dll (strategy what to do in that case TBD)

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.

7 participants