diff --git a/src/Aspire.Cli/Npm/NpmRunner.cs b/src/Aspire.Cli/Npm/NpmRunner.cs index 96b47a06b94..3094eb6a212 100644 --- a/src/Aspire.Cli/Npm/NpmRunner.cs +++ b/src/Aspire.Cli/Npm/NpmRunner.cs @@ -276,6 +276,11 @@ internal static ProcessStartInfo CreateNpmProcessStartInfo(string npmPath, strin { var startInfo = new ProcessStartInfo { + // Redirect stdin so the child npm process (and any lifecycle scripts it invokes) + // does not inherit the CLI's TTY. The caller closes stdin immediately after Start() + // so any read surfaces as EOF instead of hanging waiting on the terminal. NpmRunner + // is intended to be fully non-interactive. See https://github.com/microsoft/aspire/issues/16791. + RedirectStandardInput = true, RedirectStandardOutput = true, RedirectStandardError = true, UseShellExecute = false, @@ -357,6 +362,17 @@ internal static bool TryExtractLastVersion(string npmOutput, [NotNullWhen(true)] using var process = new Process { StartInfo = startInfo }; using var activity = profilingTelemetry.StartNpmCommand(npmPath, args, workingDirectory); process.Start(); + // Close stdin so any npm lifecycle script that tries to read terminal input + // sees EOF instead of blocking on the inherited TTY. See ProcessGuestLauncher + // and https://github.com/microsoft/aspire/issues/16791. + try + { + process.StandardInput.Close(); + } + catch (IOException) + { + // The child may have already closed its stdin; ignore. + } activity.SetProcessId(process.Id); var outputTask = process.StandardOutput.ReadToEndAsync(cancellationToken); diff --git a/src/Aspire.Cli/Projects/DotNetBasedAppHostServerProject.cs b/src/Aspire.Cli/Projects/DotNetBasedAppHostServerProject.cs index 1d431a973a6..f1c95bd3af4 100644 --- a/src/Aspire.Cli/Projects/DotNetBasedAppHostServerProject.cs +++ b/src/Aspire.Cli/Projects/DotNetBasedAppHostServerProject.cs @@ -518,10 +518,23 @@ public async Task PrepareAsync( _logger.LogDebug("Enabling debug logging for AppHostServer"); } + startInfo.RedirectStandardInput = true; startInfo.RedirectStandardOutput = true; startInfo.RedirectStandardError = true; var process = Process.Start(startInfo)!; + // Close the redirected stdin pipe immediately so the AppHost server process — and any + // child/library it loads — observes EOF rather than blocking on the parent CLI's TTY + // if it ever reads from stdin. The CLI communicates with the server over a Unix socket + // (REMOTE_APP_HOST_SOCKET_PATH), not stdin. See https://github.com/microsoft/aspire/issues/16791. + try + { + process.StandardInput.Close(); + } + catch (IOException) + { + // The child may have already closed its stdin; ignore. + } var outputCollector = new OutputCollector(); process.OutputDataReceived += (sender, e) => diff --git a/src/Aspire.Cli/Projects/PrebuiltAppHostServer.cs b/src/Aspire.Cli/Projects/PrebuiltAppHostServer.cs index d87fc185867..81b0c99591e 100644 --- a/src/Aspire.Cli/Projects/PrebuiltAppHostServer.cs +++ b/src/Aspire.Cli/Projects/PrebuiltAppHostServer.cs @@ -855,6 +855,18 @@ private static string GetRestoreVersion(string packageName, string version, bool var startInfo = CreateStartInfo(hostPid, environmentVariables, additionalArgs, debug); var process = Process.Start(startInfo)!; + // Close the redirected stdin pipe immediately so the AppHost server process — and any + // child/library it loads — observes EOF rather than blocking on the parent CLI's TTY + // if it ever reads from stdin. The CLI communicates with the server over a Unix socket + // (REMOTE_APP_HOST_SOCKET_PATH), not stdin. See https://github.com/microsoft/aspire/issues/16791. + try + { + process.StandardInput.Close(); + } + catch (IOException) + { + // The child may have already closed its stdin; ignore. + } var outputCollector = new OutputCollector(); process.OutputDataReceived += (_, e) => @@ -980,6 +992,7 @@ internal ProcessStartInfo CreateStartInfo( startInfo.Environment[KnownConfigNames.AspireLogLevel] = "Debug"; } + startInfo.RedirectStandardInput = true; startInfo.RedirectStandardOutput = true; startInfo.RedirectStandardError = true; diff --git a/src/Aspire.Cli/Projects/ProcessGuestLauncher.cs b/src/Aspire.Cli/Projects/ProcessGuestLauncher.cs index fdf137100f7..75500ebb8f0 100644 --- a/src/Aspire.Cli/Projects/ProcessGuestLauncher.cs +++ b/src/Aspire.Cli/Projects/ProcessGuestLauncher.cs @@ -57,6 +57,13 @@ public ProcessGuestLauncher(string language, ILogger logger, FileLoggerProvider? { FileName = resolvedCommandPath, WorkingDirectory = workingDirectory.FullName, + // Redirect stdin so the child does not inherit the CLI's TTY. Without this, on macOS/Linux + // any child (e.g. `npm install` postinstall scripts, husky, package-manager permission + // prompts) that reads from stdin will block forever waiting on the terminal, making + // `aspire new`/`init`/`add`/`restore` appear to stall with no output. We close stdin + // immediately after Start() below so a reader sees EOF instead of hanging. + // See https://github.com/microsoft/aspire/issues/16791. + RedirectStandardInput = true, RedirectStandardOutput = true, RedirectStandardError = true, UseShellExecute = false, @@ -123,6 +130,17 @@ public ProcessGuestLauncher(string language, ILogger logger, FileLoggerProvider? AddEvent(activity, ProfilingTelemetry.Events.GuestProcessStart); process.Start(); + // Close the redirected stdin pipe immediately so any read attempt in the child surfaces + // as EOF rather than blocking on an empty pipe. We never write to the guest process + // stdin, so this is safe. + try + { + process.StandardInput.Close(); + } + catch (IOException) + { + // The child may have already closed its stdin; ignore. + } activity?.SetTag(TelemetryConstants.Tags.ProcessPid, process.Id); AddEvent(activity, ProfilingTelemetry.Events.GuestProcessStarted, TelemetryConstants.Tags.ProcessPid, process.Id); if (afterLaunchAsync is not null) diff --git a/tests/Aspire.Cli.Tests/Projects/GuestRuntimeTests.cs b/tests/Aspire.Cli.Tests/Projects/GuestRuntimeTests.cs index 4b8a5607d88..55848ddb7ab 100644 --- a/tests/Aspire.Cli.Tests/Projects/GuestRuntimeTests.cs +++ b/tests/Aspire.Cli.Tests/Projects/GuestRuntimeTests.cs @@ -705,6 +705,53 @@ public async Task ProcessGuestLauncher_AnnotatesAmbientGuestProfilingActivity() Assert.Contains(activity.Events, @event => @event.Name == ProfilingTelemetry.Events.GuestProcessExited); } + [Fact] + public async Task ProcessGuestLauncher_ClosesChildStdinSoReadsObserveEof() + { + // Regression coverage for https://github.com/microsoft/aspire/issues/16791. + // Before this fix, ProcessGuestLauncher did not redirect/close stdin, so a child + // process (e.g. `npm install` postinstall scripts on macOS) inherited the parent + // CLI's TTY and any stdin read blocked forever - making `aspire new` for the + // TypeScript starter appear to stall. + var launcher = new ProcessGuestLauncher( + "test", + _loggerFactory.CreateLogger()); + + string command; + string[] args; + if (OperatingSystem.IsWindows()) + { + // `set /p` reads a line from stdin. With redirected+closed stdin it sees EOF and + // exits immediately. With an inherited or open-empty stdin it would block. + command = "cmd.exe"; + args = ["/c", "set /p line=(), + cts.Token); + + stopwatch.Stop(); + + Assert.False(cts.IsCancellationRequested, + $"Child process did not exit on its own within 10s - stdin may not have been closed. Elapsed: {stopwatch.Elapsed}."); + Assert.Equal(0, exitCode); + var lines = output?.GetLines().Select(l => l.Line).ToArray() ?? []; + Assert.Contains(lines, l => l.Contains("eof", StringComparison.OrdinalIgnoreCase)); + } + [Fact] public async Task ProcessGuestLauncher_KillsProcessAndReturnsOnCancellation() {