Skip to content

fix shutdown tests - #129853

Merged
rosebyte merged 21 commits into
dotnet:mainfrom
rosebyte:shutdown-tests_deployer-fix
Aug 17, 2026
Merged

fix shutdown tests#129853
rosebyte merged 21 commits into
dotnet:mainfrom
rosebyte:shutdown-tests_deployer-fix

Conversation

@rosebyte

@rosebyte rosebyte commented Jun 25, 2026

Copy link
Copy Markdown
Member

Fixes #129832

Summary

This change makes the functional test deployer more robust in Helix environments where dotnet is not on PATH and where process start can fail transiently.

  • Resolve the default dotnet host from the active testhost layout (next to the shared framework) instead of assuming a global PATH entry.
  • Keep the x86-on-x64 override behaviour unchanged by still resolving the architecture-specific host when required.
  • Harden shutdown logic so cleanup does not throw when a Process object was created but never successfully started.
  • Add retry logic for self-host process launch (3 attempts with short delay) to tolerate transient CI/Helix launch failures.
  • Preserve existing startup signalling and error propagation semantics while improving diagnostics around failed launches.

Testing

  • Existing shutdown functional tests (the target failing scenarios in CI)

Note

This PR description was generated with AI assistance.

Copilot AI lite review requested due to automatic review settings June 25, 2026 14:08
@dotnet-policy-service

Copy link
Copy Markdown
Contributor

Tagging subscribers to this area: @dotnet/area-extensions-hosting
See info in area-owners.md if you want to be subscribed.

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

This PR updates the Microsoft.Extensions.Hosting functional-test deployment infrastructure to make shutdown-related tests more reliable on constrained/Helix environments by hardening process launch and shutdown handling.

Changes:

  • Add a retry loop when launching the self-hosted test process, and surface early-exit failures more consistently.
  • Resolve the dotnet muxer path relative to the currently running shared framework (with PATH fallback) for Helix environments that don’t provide a global dotnet.
  • Avoid Process.HasExited throwing during shutdown/cleanup when the Process object was never successfully started.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 2 comments.

File Description
src/libraries/Microsoft.Extensions.Hosting/tests/FunctionalTests/IntegrationTesting/src/Deployers/SelfHostDeployer.cs Adds start retry logic and reorders startup flow to reduce flaky failures from transient process-launch issues.
src/libraries/Microsoft.Extensions.Hosting/tests/FunctionalTests/IntegrationTesting/src/Deployers/ApplicationDeployer.cs Improves dotnet muxer resolution for Helix and hardens shutdown checks against unstarted Process instances.

Copilot AI review requested due to automatic review settings June 30, 2026 07:41

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

Copilot reviewed 2 out of 2 changed files in this pull request and generated 3 comments.

@MichalStrehovsky

Copy link
Copy Markdown
Member

I'm disabling these tests in crossgen2 and native AOT testing in #130066 because this has been broken for too long and makes it difficult to find real issues.

Please remove the disabling and run /azp run runtime-nativeaot-outerloop and /azp run runtime-coreclr crossgen2-outerloop before merging.

@rosebyte

rosebyte commented Jul 1, 2026

Copy link
Copy Markdown
Member Author

/azp run runtime-nativeaot-outerloop

@azure-pipelines

Copy link
Copy Markdown
Azure Pipelines successfully started running 1 pipeline(s).

@jkotas

jkotas commented Jul 1, 2026

Copy link
Copy Markdown
Member

@rosebyte /azp run merges from main locally before doing the build, so the shutdown tests won't run. I think you need to merge from main into the PR and revert the change from #130066 to see the tests running.

@github-actions

github-actions Bot commented Jul 19, 2026

Copy link
Copy Markdown
Contributor

Workflow state for the Holistic Review Orchestrator.

{
  "version": 5,
  "last_dispatched_commit": "25d32f0c830cecd53dcdacbf88f21022a2e5f57b",
  "last_dispatched_base_ref": "main",
  "last_dispatched_base_sha": "e3f7eec1b9f39efe769c3cd316181110546500f8",
  "last_reviewed_commit": "25d32f0c830cecd53dcdacbf88f21022a2e5f57b",
  "last_reviewed_base_ref": "main",
  "last_reviewed_base_sha": "e3f7eec1b9f39efe769c3cd316181110546500f8",
  "last_recorded_worker_run_id": "29679143706",
  "review_attempt_commit": "",
  "review_attempt_base_ref": "",
  "review_attempt_count": 0,
  "max_review_attempts": 5,
  "review_history_format": "holistic-review-disclosure-v1",
  "review_history": [
    {
      "commit": "25d32f0c830cecd53dcdacbf88f21022a2e5f57b",
      "review_id": 4730523149
    }
  ]
}

@github-actions github-actions Bot 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.

Holistic Review

Motivation: Justified. Linked issue #129832 shows a real, reproducible failure: ShutDownIfAnyHostProcess calls Process.HasExited on a Process that was created but never successfully started, throwing InvalidOperationException ("No process is associated with this object") during Dispose, which masks the real launch failure. The stack trace in the issue matches the code paths touched here.

Approach: Reasonable and targeted for test infrastructure. The IsRunning helper that swallows InvalidOperationException directly addresses the throwing HasExited call; adding throw; in the launch catch surfaces the real start failure instead of hitting the misleading HasExited/ExitCode access later; RunContinuationsAsynchronously avoids inline continuations on the Exited event thread; and GetHostDotNetExecutable resolves the muxer from the running host/shared-framework layout for Helix where dotnet isn't on PATH. These are consistent with the codebase and low-risk.

Summary: ⚠️ Needs Human Review. The code changes are individually sound and well-scoped test-infra hardening that plausibly fixes #129832, but this is a functional/Helix-only failure that cannot be reproduced or run in this environment, so a maintainer should confirm the fix against the actual crossgen2/nativeaot outerloop pipelines (as already requested in the PR thread). One documentation discrepancy should be corrected, and a couple of low-confidence robustness points are worth a glance.


Detailed Findings

✅ Correctness — Core fix is correct

IsRunning (ApplicationDeployer.cs) correctly treats a never-started/disposed Process as not running by catching InvalidOperationException, which is exactly the exception from the issue's stack trace. Replacing both !hostProcess.HasExited checks in ShutDownIfAnyHostProcess with IsRunning makes cleanup non-throwing without masking real state. Adding throw; in StartSelfHostAsync's catch is the right complement: it propagates the genuine launch error rather than letting the subsequent HostProcess.HasExited access throw a misleading InvalidOperationException.

⚠️ Documentation — PR description claims retry logic that is not in the diff

The PR description states: "Add retry logic for self-host process launch (3 attempts with short delay) to tolerate transient CI/Helix launch failures." The current base-to-head diff contains no retry loop — StartAndCaptureOutAndErrToLogger is still called exactly once inside a single try/catch that now rethrows. Please update the description (it notes it was AI-generated) so it matches the actual change, or add the retry if it was intended. As written, the description overstates the change.

💡 Robustness — GetHostDotNetExecutable fallback assumptions (low confidence, test-only)

Two minor, non-blocking observations on the new helper:

  • The muxer fallback assumes dotnet lives exactly three directories above typeof(object).Assembly.Location (shared/Microsoft.NETCore.App/<version> → root). This holds for the standard testhost/dotnet layout and self-contained builds may differ, but the method falls back to DotnetCommandName, so worst case is the prior behavior. Fine as-is.
  • Process.GetCurrentProcess().MainModule?.FileName can throw on some restricted platforms; in the normal test host it is fine. Given this is test infrastructure and the failure mode is only a fallback, not worth guarding, but flagging for visibility.

✅ Concurrency — RunContinuationsAsynchronously and reordering

Switching started to TaskCreationOptions.RunContinuationsAsynchronously avoids running the awaiting continuation inline on the process Exited/output-reader thread, which is a good default. Moving the hostExitTokenSource declaration earlier is a no-op reorder with no semantic impact.

Note

This review was generated by this repository's Holistic Review agentic workflow to complement the built-in Copilot review.

Generated by Holistic Review · 59 AIC · ⌖ 10.9 AIC · ⊞ 10K

Copilot AI review requested due to automatic review settings July 29, 2026 16:42
Copilot AI review requested due to automatic review settings August 13, 2026 10:58

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

Copilot reviewed 6 out of 6 changed files in this pull request and generated no new comments.

Suppressed comments (4)

src/libraries/Microsoft.Extensions.Hosting/tests/FunctionalTests/IntegrationTesting/src/Common/DotNetCommands.cs:46

  • Environment.ProcessPath is declared as nullable (string?), so Path.GetFileName(processPath) can throw ArgumentNullException when the process path is unavailable. Since DotNetMuxerPath is meant to be a best-effort probe, this should treat a null ProcessPath as “no muxer found” and return null instead of throwing during type initialization.
            var processPath = Environment.ProcessPath;
            return string.Equals(Path.GetFileName(processPath), DotNetExecutableName, StringComparison.OrdinalIgnoreCase)
                ? processPath
                : null;

eng/testing/RunnerTemplate.sh:28

  • RUNTIME_PATH is now exported, but the assignment is unquoted. Quoting avoids breakage if the runtime path ever contains spaces or shell-special characters, and matches the quoting used in other runner templates (e.g., BionicRunnerTemplate.sh).
      # Exported so that tests which need to launch a portable application of their own can find the
      # same host the test run itself was handed. RunnerTemplate.cmd's "set" already does this.
      export RUNTIME_PATH=$2
      shift

src/libraries/Microsoft.Extensions.Hosting/tests/FunctionalTests/IntegrationTesting/src/Deployers/ApplicationDeployer.cs:103

  • This new muxer-resolution helper is only used for launching portable apps, but dotnet publish still hardcodes FileName = "dotnet" in ApplicationPublisher (IntegrationTesting/src/ApplicationPublisher.cs). In Helix environments where dotnet isn’t on PATH, publishing will still fail, so the deployer isn’t fully hardened end-to-end. Consider switching ApplicationPublisher to use the same resolved muxer path (or plumb it in) so both publish and launch work without relying on PATH.
        protected static string GetDotNetMuxerPath()
            => DotNetCommands.DotNetMuxerPath
                ?? throw new Exception($"Unable to find '{DotNetCommands.DotNetExecutableName}'.");

src/libraries/Microsoft.Extensions.Hosting/tests/FunctionalTests/IntegrationTesting/src/Deployers/SelfHostDeployer.cs:156

  • The PR description mentions adding retry logic for self-host process launch (3 attempts) and keeping x86-on-x64 muxer selection behavior unchanged. In the current code, StartSelfHostAsync still calls StartAndCaptureOutAndErrToLogger once with no retry, and the previous architecture-specific dotnet resolution helper was removed. If retries / arch-specific muxer selection are still intended, they should be implemented here; otherwise the PR description should be updated to match the actual change.
                try
                {
                    HostProcess.StartAndCaptureOutAndErrToLogger(executableName, Logger);
                }
                catch (Exception ex)
                {
                    // Surface the real launch failure instead of letting it be masked later during disposal.
                    Logger.LogError("Error occurred while starting the process. Exception: {exception}", ex.ToString());
                    throw;

Copilot AI review requested due to automatic review settings August 14, 2026 04:39

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

Copilot reviewed 6 out of 6 changed files in this pull request and generated no new comments.

Suppressed comments (3)

src/libraries/Microsoft.Extensions.Hosting/tests/FunctionalTests/IntegrationTesting/src/Deployers/SelfHostDeployer.cs:151

  • The PR description states that self-host process launch has retry logic (e.g., 3 attempts with a short delay), but the current implementation still does a single StartAndCaptureOutAndErrToLogger call and immediately rethrows on failure. Either add the intended retry loop here (ensuring each failed attempt cleans up/terminates any partially-started process) or update the PR description to reflect the actual behavior.
                try
                {
                    HostProcess.StartAndCaptureOutAndErrToLogger(executableName, Logger);
                }

src/libraries/Microsoft.Extensions.Hosting/tests/FunctionalTests/IntegrationTesting/src/Common/DotNetCommands.cs:21

  • DotNetMuxerPath (and FindDotNetMuxer) are documented/implemented to return null when no suitable muxer is available, but the members are declared as non-nullable string. In this repo (nullable enabled), this will either produce nullable warnings or mislead callers into assuming a non-null value. Make the API explicitly nullable (string?) to match the behavior and existing call sites that already handle null.
        /// <summary>
        /// Gets the full path of the muxer that portable applications are launched with, or
        /// <see langword="null"/> when the current test environment has not got one.
        /// </summary>
        public static string DotNetMuxerPath { get; } = FindDotNetMuxer();

eng/testing/RunnerTemplate.sh:27

  • RUNTIME_PATH assignment should be quoted to avoid word-splitting if the runtime path contains spaces or glob characters. Even if uncommon in Helix, quoting is low-cost and more robust.
      # Exported so that tests which need to launch a portable application of their own can find the
      # same host the test run itself was handed. RunnerTemplate.cmd's "set" already does this.
      export RUNTIME_PATH=$2
      shift

Copilot AI review requested due to automatic review settings August 14, 2026 11:42

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

Copilot reviewed 6 out of 6 changed files in this pull request and generated no new comments.

Suppressed comments (3)

src/libraries/Microsoft.Extensions.Hosting/tests/FunctionalTests/IntegrationTesting/src/Common/DotNetCommands.cs:30

  • FindDotNetMuxer assumes Environment.ProcessPath is non-null, but ProcessPath can be null in some hosting scenarios; Path.GetFileName(null) will throw during type initialization and can break test discovery. Consider returning null when ProcessPath is null, and make the method return type nullable to match the actual return paths.
        private static string FindDotNetMuxer()
        {
            var runtimePath = Environment.GetEnvironmentVariable(RuntimePathVariableName);
            if (!string.IsNullOrEmpty(runtimePath))
            {

src/libraries/Microsoft.Extensions.Hosting/tests/FunctionalTests/IntegrationTesting/src/Common/DotNetCommands.cs:22

  • DotNetMuxerPath is documented/used as potentially null (and FindDotNetMuxer returns null in several branches), but the property is declared as non-nullable string. Mark it as nullable to match its actual contract and avoid misleading API surface in this helper class.

This issue also appears on line 26 of the same file.

        /// <summary>
        /// Gets the full path of the muxer that portable applications are launched with, or
        /// <see langword="null"/> when the current test environment has not got one.
        /// </summary>
        public static string DotNetMuxerPath { get; } = FindDotNetMuxer();

eng/testing/RunnerTemplate.sh:28

  • RUNTIME_PATH is assigned from a CLI argument; quoting the value prevents breakage if the runtime path contains spaces or other shell-special characters.
      # Exported so that tests which need to launch a portable application of their own can find the
      # same host the test run itself was handed. RunnerTemplate.cmd's "set" already does this.
      export RUNTIME_PATH=$2
      shift

Copilot AI review requested due to automatic review settings August 15, 2026 06:17

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

Copilot reviewed 6 out of 6 changed files in this pull request and generated no new comments.

Suppressed comments (2)

src/libraries/Microsoft.Extensions.Hosting/tests/FunctionalTests/ShutdownTests.cs:43

  • CanDeployTestApplication checks for the app .dll and .deps.json, but launching a framework-dependent app via dotnet <app>.dll also requires <app>.runtimeconfig.json. Without checking it, the test can run in layouts where start will inevitably fail (and then be skipped only after a noisy failure).
                var applicationPath = Path.Combine(AppContext.BaseDirectory, TestApplicationName);
                return File.Exists(applicationPath + ".dll") && File.Exists(applicationPath + ".deps.json");

src/libraries/Microsoft.Extensions.Hosting/tests/FunctionalTests/IntegrationTesting/src/Common/DotNetCommands.cs:46

  • Environment.ProcessPath can be null/empty in some environments. Calling Path.GetFileName(processPath) without guarding can throw, which would make DotNetMuxerPath initialization fail and potentially break unrelated tests. Guard for null/empty before using it.
            var processPath = Environment.ProcessPath;
            return string.Equals(Path.GetFileName(processPath), DotNetExecutableName, StringComparison.OrdinalIgnoreCase)
                ? processPath
                : null;

Copilot AI review requested due to automatic review settings August 16, 2026 08:26

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

Copilot reviewed 6 out of 6 changed files in this pull request and generated no new comments.

Suppressed comments (3)

src/libraries/Microsoft.Extensions.Hosting/tests/FunctionalTests/ShutdownTests.cs:43

  • CanDeployTestApplication checks for *.dll and *.deps.json, but portable execution also requires the app's *.runtimeconfig.json. Without it, dotnet <app>.dll fails even when the other files exist, so the test will run and fail instead of being conditionally skipped.
                return File.Exists(applicationPath + ".dll") && File.Exists(applicationPath + ".deps.json");

src/libraries/Microsoft.Extensions.Hosting/tests/FunctionalTests/IntegrationTesting/src/Common/DotNetCommands.cs:46

  • Environment.ProcessPath is nullable, but this code passes it directly to Path.GetFileName(processPath). If ProcessPath is unavailable (e.g., restricted environments or after early access failure), this will throw ArgumentNullException during type initialization and break all callers of DotNetMuxerPath. Handle the null case explicitly before calling Path.*.
            var processPath = Environment.ProcessPath;
            return string.Equals(Path.GetFileName(processPath), DotNetExecutableName, StringComparison.OrdinalIgnoreCase)
                ? processPath
                : null;

src/libraries/Microsoft.Extensions.Hosting/tests/FunctionalTests/IntegrationTesting/src/Deployers/SelfHostDeployer.cs:156

  • The PR description claims a retry loop for self-host process launch ("3 attempts with short delay"), but StartSelfHostAsync currently logs and rethrows immediately on the first StartAndCaptureOutAndErrToLogger failure. Either implement the described retry behavior here or update the PR description to match the current code.
                    // Surface the real launch failure instead of letting it be masked later during disposal.
                    Logger.LogError("Error occurred while starting the process. Exception: {exception}", ex.ToString());
                    throw;

@jkotas

jkotas commented Aug 16, 2026

Copy link
Copy Markdown
Member

If you are merging from main to get a green CI, it won't work due to dotnet/arcade#17340. You can suppress the failure using ba-g

@rosebyte

Copy link
Copy Markdown
Member Author

/ba-g all known issues already filed #132336

@rosebyte
rosebyte merged commit 92ad645 into dotnet:main Aug 17, 2026
130 of 135 checks passed
@dotnet-milestone-bot dotnet-milestone-bot Bot added this to the 12.0-preview1 milestone Aug 18, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Test failure: Microsoft.AspNetCore.Hosting.FunctionalTests.ShutdownTests.ShutdownTestRun

4 participants