From e4fb837bd953610fba8c918bc526e751ca8408d0 Mon Sep 17 00:00:00 2001 From: Gilles <43683714+corp-0@users.noreply.github.com> Date: Thu, 4 Jun 2026 23:58:10 -0400 Subject: [PATCH 1/4] fix: ensure termination of TTS process when closing the application --- .../RecordingHostApplicationLifetime.cs | 25 ++++ .../HostWatchdogHostedServiceTests.cs | 74 ++++++++++++ .../Services/ProcessExitWatcherTests.cs | 19 +++ .../Services/TtsShutdownHostedServiceTests.cs | 62 ++++++++++ .../Host/Setup/AppConfiguration.cs | 29 +++++ src-dotnet/PuduLauncher/Interop/JobObjects.cs | 112 ++++++++++++++++++ src-dotnet/PuduLauncher/Program.cs | 3 + src-dotnet/PuduLauncher/PuduLauncher.csproj | 1 + .../Services/HostWatchdogHostedService.cs | 65 ++++++++++ .../Interfaces/IProcessExitWatcher.cs | 14 +++ .../Services/ProcessExitWatcher.cs | 34 ++++++ .../PuduLauncher/Services/TtsServerService.cs | 49 ++++++++ .../Services/TtsShutdownHostedService.cs | 31 +++++ src-tauri/src/sidecar.rs | 61 +++++++++- 14 files changed, 573 insertions(+), 6 deletions(-) create mode 100644 src-dotnet/PuduLauncher.Tests/Infrastructure/RecordingHostApplicationLifetime.cs create mode 100644 src-dotnet/PuduLauncher.Tests/Services/HostWatchdogHostedServiceTests.cs create mode 100644 src-dotnet/PuduLauncher.Tests/Services/ProcessExitWatcherTests.cs create mode 100644 src-dotnet/PuduLauncher.Tests/Services/TtsShutdownHostedServiceTests.cs create mode 100644 src-dotnet/PuduLauncher/Interop/JobObjects.cs create mode 100644 src-dotnet/PuduLauncher/Services/HostWatchdogHostedService.cs create mode 100644 src-dotnet/PuduLauncher/Services/Interfaces/IProcessExitWatcher.cs create mode 100644 src-dotnet/PuduLauncher/Services/ProcessExitWatcher.cs create mode 100644 src-dotnet/PuduLauncher/Services/TtsShutdownHostedService.cs diff --git a/src-dotnet/PuduLauncher.Tests/Infrastructure/RecordingHostApplicationLifetime.cs b/src-dotnet/PuduLauncher.Tests/Infrastructure/RecordingHostApplicationLifetime.cs new file mode 100644 index 0000000..385a289 --- /dev/null +++ b/src-dotnet/PuduLauncher.Tests/Infrastructure/RecordingHostApplicationLifetime.cs @@ -0,0 +1,25 @@ +using Microsoft.Extensions.Hosting; + +namespace PuduLauncher.Tests.Infrastructure; + +/// Test double for IHostApplicationLifetime that records StopApplication(). +public sealed class RecordingHostApplicationLifetime : IHostApplicationLifetime +{ + private readonly TaskCompletionSource _stopRequested = + new(TaskCreationOptions.RunContinuationsAsynchronously); + + public bool StopApplicationCalled { get; private set; } + + /// Completes the first time StopApplication() is called. + public Task StopRequested => _stopRequested.Task; + + public CancellationToken ApplicationStarted => CancellationToken.None; + public CancellationToken ApplicationStopping => CancellationToken.None; + public CancellationToken ApplicationStopped => CancellationToken.None; + + public void StopApplication() + { + StopApplicationCalled = true; + _stopRequested.TrySetResult(); + } +} diff --git a/src-dotnet/PuduLauncher.Tests/Services/HostWatchdogHostedServiceTests.cs b/src-dotnet/PuduLauncher.Tests/Services/HostWatchdogHostedServiceTests.cs new file mode 100644 index 0000000..74de21e --- /dev/null +++ b/src-dotnet/PuduLauncher.Tests/Services/HostWatchdogHostedServiceTests.cs @@ -0,0 +1,74 @@ +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging.Abstractions; +using PuduLauncher.Services; +using PuduLauncher.Services.Interfaces; +using PuduLauncher.Tests.Infrastructure; + +namespace PuduLauncher.Tests.Services; + +public class HostWatchdogHostedServiceTests +{ + [Fact] + public async Task WhenHostPidExits_RequestsApplicationStop() + { + var watcher = new ControllableProcessExitWatcher(); + var lifetime = new RecordingHostApplicationLifetime(); + var service = CreateService(hostPid: "4242", watcher, lifetime); + + await service.StartAsync(CancellationToken.None); + watcher.SignalExit(); + + var completed = await Task.WhenAny(lifetime.StopRequested, Task.Delay(TimeSpan.FromSeconds(5))); + Assert.Equal(lifetime.StopRequested, completed); + Assert.True(lifetime.StopApplicationCalled); + Assert.Equal(4242, watcher.WatchedPid); + + await service.StopAsync(CancellationToken.None); + } + + [Fact] + public async Task WhenNoHostPid_IsInert() + { + var watcher = new ControllableProcessExitWatcher(); + var lifetime = new RecordingHostApplicationLifetime(); + var service = CreateService(hostPid: null, watcher, lifetime); + + await service.StartAsync(CancellationToken.None); + await service.StopAsync(CancellationToken.None); + + Assert.False(watcher.WaitCalled); + Assert.False(lifetime.StopApplicationCalled); + } + + private static HostWatchdogHostedService CreateService( + string? hostPid, IProcessExitWatcher watcher, RecordingHostApplicationLifetime lifetime) + { + var settings = new Dictionary(); + if (hostPid is not null) settings["host-pid"] = hostPid; + var configuration = new ConfigurationBuilder().AddInMemoryCollection(settings).Build(); + + return new HostWatchdogHostedService( + configuration, watcher, lifetime, NullLogger.Instance); + } + + private sealed class ControllableProcessExitWatcher : IProcessExitWatcher + { + private readonly TaskCompletionSource _exit = + new(TaskCreationOptions.RunContinuationsAsynchronously); + + public bool WaitCalled { get; private set; } + public int WatchedPid { get; private set; } + + public void SignalExit() => _exit.TrySetResult(); + + public async Task WaitForProcessExitAsync(int processId, CancellationToken cancellationToken) + { + WaitCalled = true; + WatchedPid = processId; + await using (cancellationToken.Register(() => _exit.TrySetCanceled())) + { + await _exit.Task; + } + } + } +} diff --git a/src-dotnet/PuduLauncher.Tests/Services/ProcessExitWatcherTests.cs b/src-dotnet/PuduLauncher.Tests/Services/ProcessExitWatcherTests.cs new file mode 100644 index 0000000..c0f6d36 --- /dev/null +++ b/src-dotnet/PuduLauncher.Tests/Services/ProcessExitWatcherTests.cs @@ -0,0 +1,19 @@ +using PuduLauncher.Services; + +namespace PuduLauncher.Tests.Services; + +public class ProcessExitWatcherTests +{ + [Fact] + public async Task WaitForProcessExitAsync_WhenPidDoesNotExist_ReturnsImmediately() + { + var watcher = new ProcessExitWatcher(); + + // PID very unlikely to exist; method should return without throwing. + var task = watcher.WaitForProcessExitAsync(int.MaxValue - 1, CancellationToken.None); + var completed = await Task.WhenAny(task, Task.Delay(TimeSpan.FromSeconds(5))); + + Assert.Equal(task, completed); + await task; + } +} diff --git a/src-dotnet/PuduLauncher.Tests/Services/TtsShutdownHostedServiceTests.cs b/src-dotnet/PuduLauncher.Tests/Services/TtsShutdownHostedServiceTests.cs new file mode 100644 index 0000000..1f7030a --- /dev/null +++ b/src-dotnet/PuduLauncher.Tests/Services/TtsShutdownHostedServiceTests.cs @@ -0,0 +1,62 @@ +using Microsoft.Extensions.Logging.Abstractions; +using PuduLauncher.Models.Config; +using PuduLauncher.Services; +using PuduLauncher.Services.Interfaces; + +namespace PuduLauncher.Tests.Services; + +public class TtsShutdownHostedServiceTests +{ + [Fact] + public async Task StopAsync_StopsServerWithConfiguredInstallPath() + { + var serverService = new RecordingTtsServerService(); + var preferences = new StubPreferencesService("C:\\tts\\install"); + var service = new TtsShutdownHostedService( + serverService, preferences, NullLogger.Instance); + + await service.StopAsync(CancellationToken.None); + + Assert.True(serverService.StopAsyncCalled); + Assert.Equal("C:\\tts\\install", serverService.LastInstallPath); + } + + [Fact] + public async Task StopAsync_WhenStopThrows_DoesNotRethrow() + { + var serverService = new RecordingTtsServerService { ThrowOnStop = true }; + var preferences = new StubPreferencesService("/tts/install"); + var service = new TtsShutdownHostedService( + serverService, preferences, NullLogger.Instance); + + await service.StopAsync(CancellationToken.None); // must not throw + } + + private sealed class RecordingTtsServerService : ITtsServerService + { + public bool ThrowOnStop { get; init; } + public bool StopAsyncCalled { get; private set; } + public string? LastInstallPath { get; private set; } + + public bool IsRunning => false; + public Task StartAsync(string installPath, CancellationToken ct = default) => Task.CompletedTask; + + public Task StopAsync(string? installPath = null, CancellationToken ct = default) + { + StopAsyncCalled = true; + LastInstallPath = installPath; + if (ThrowOnStop) throw new InvalidOperationException("boom"); + return Task.CompletedTask; + } + + public Task WaitForHealthAsync(CancellationToken ct = default) => Task.CompletedTask; + public void Dispose() { } + } + + private sealed class StubPreferencesService(string installPath) : IPreferencesService + { + private readonly Preferences _preferences = new() { Tts = new TtsPreferences { InstallPath = installPath } }; + public Preferences GetPreferences() => _preferences; + public Task UpdatePreferencesAsync(Preferences preferences) => Task.CompletedTask; + } +} diff --git a/src-dotnet/PuduLauncher/Host/Setup/AppConfiguration.cs b/src-dotnet/PuduLauncher/Host/Setup/AppConfiguration.cs index b0faa10..f7e3d90 100644 --- a/src-dotnet/PuduLauncher/Host/Setup/AppConfiguration.cs +++ b/src-dotnet/PuduLauncher/Host/Setup/AppConfiguration.cs @@ -76,6 +76,35 @@ public static async Task StartAsSidecarAsync(this WebApplication app) app.Logger.LogInformation("PuduLauncher Sidecar started on port {Port}", port); + // Listen for a graceful shutdown signal from the Rust host over stdin. + // A "SHUTDOWN" line or stdin EOF triggers a graceful host shutdown, which + // lets TtsShutdownHostedService stop the TTS server before we exit. + _ = Task.Run(() => ListenForShutdownAsync(app)); + await app.WaitForShutdownAsync(); } + + private static async Task ListenForShutdownAsync(WebApplication app) + { + try + { + while (true) + { + string? line = await Console.In.ReadLineAsync(); + + // Null means stdin reached EOF (the host went away). Either an + // explicit SHUTDOWN or EOF should trigger a graceful shutdown. + if (line is null || string.Equals(line.Trim(), "SHUTDOWN", StringComparison.OrdinalIgnoreCase)) + { + app.Logger.LogInformation("Received shutdown signal via stdin"); + app.Lifetime.StopApplication(); + return; + } + } + } + catch (Exception ex) + { + app.Logger.LogWarning(ex, "stdin shutdown listener stopped unexpectedly"); + } + } } diff --git a/src-dotnet/PuduLauncher/Interop/JobObjects.cs b/src-dotnet/PuduLauncher/Interop/JobObjects.cs new file mode 100644 index 0000000..e9c4679 --- /dev/null +++ b/src-dotnet/PuduLauncher/Interop/JobObjects.cs @@ -0,0 +1,112 @@ +using System.Runtime.InteropServices; +using System.Runtime.Versioning; + +namespace PuduLauncher.Interop; + +/// +/// Minimal Win32 Job Object interop. Used to bind the TTS process tree's +/// lifetime to the sidecar: a job created with KILL_ON_JOB_CLOSE terminates all +/// its processes when the last handle (held by the sidecar) is closed, including +/// when the sidecar is force-killed. +/// +[SupportedOSPlatform("windows")] +internal static partial class JobObjects +{ + private const int JobObjectExtendedLimitInformation = 9; + private const uint JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE = 0x2000; + + [LibraryImport("kernel32.dll", SetLastError = true)] + private static partial IntPtr CreateJobObjectW(IntPtr lpJobAttributes, IntPtr lpName); + + [LibraryImport("kernel32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static partial bool SetInformationJobObject( + IntPtr hJob, int jobObjectInformationClass, IntPtr lpJobObjectInformation, uint cbJobObjectInformationLength); + + [LibraryImport("kernel32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static partial bool AssignProcessToJobObject(IntPtr hJob, IntPtr hProcess); + + [LibraryImport("kernel32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static partial bool CloseHandle(IntPtr hObject); + + /// + /// Creates a job configured to kill all member processes when its last handle + /// closes. Returns IntPtr.Zero on failure. + /// + internal static unsafe IntPtr CreateKillOnCloseJob() + { + IntPtr handle = CreateJobObjectW(IntPtr.Zero, IntPtr.Zero); + if (handle == IntPtr.Zero) + { + return IntPtr.Zero; + } + + JOBOBJECT_EXTENDED_LIMIT_INFORMATION info = default; + info.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE; + + bool ok = SetInformationJobObject( + handle, + JobObjectExtendedLimitInformation, + (IntPtr)(&info), + (uint)sizeof(JOBOBJECT_EXTENDED_LIMIT_INFORMATION)); + + if (!ok) + { + CloseHandle(handle); + return IntPtr.Zero; + } + + return handle; + } + + /// Assigns a process to the job. Returns false on failure. + internal static bool TryAssignProcess(IntPtr jobHandle, IntPtr processHandle) + => AssignProcessToJobObject(jobHandle, processHandle); + + /// Closes a job handle. Closing the last handle kills member processes. + internal static void Close(IntPtr jobHandle) + { + if (jobHandle != IntPtr.Zero) + { + CloseHandle(jobHandle); + } + } + + [StructLayout(LayoutKind.Sequential)] + private struct JOBOBJECT_BASIC_LIMIT_INFORMATION + { + public long PerProcessUserTimeLimit; + public long PerJobUserTimeLimit; + public uint LimitFlags; + public UIntPtr MinimumWorkingSetSize; + public UIntPtr MaximumWorkingSetSize; + public uint ActiveProcessLimit; + public UIntPtr Affinity; + public uint PriorityClass; + public uint SchedulingClass; + } + + [StructLayout(LayoutKind.Sequential)] + private struct IO_COUNTERS + { + public ulong ReadOperationCount; + public ulong WriteOperationCount; + public ulong OtherOperationCount; + public ulong ReadTransferCount; + public ulong WriteTransferCount; + public ulong OtherTransferCount; + } + + [StructLayout(LayoutKind.Sequential)] + private struct JOBOBJECT_EXTENDED_LIMIT_INFORMATION + { + public JOBOBJECT_BASIC_LIMIT_INFORMATION BasicLimitInformation; + public IO_COUNTERS IoInfo; + public UIntPtr ProcessMemoryLimit; + public UIntPtr JobMemoryLimit; + public UIntPtr PeakProcessMemoryUsed; + public UIntPtr PeakJobMemoryUsed; + } +} diff --git a/src-dotnet/PuduLauncher/Program.cs b/src-dotnet/PuduLauncher/Program.cs index 28649e6..1f449b4 100644 --- a/src-dotnet/PuduLauncher/Program.cs +++ b/src-dotnet/PuduLauncher/Program.cs @@ -36,6 +36,9 @@ builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddHostedService(); + builder.Services.AddHostedService(); // ── App ─────────────────────────────────────────── WebApplication app = builder.Build(); diff --git a/src-dotnet/PuduLauncher/PuduLauncher.csproj b/src-dotnet/PuduLauncher/PuduLauncher.csproj index b3c69b2..bd79685 100644 --- a/src-dotnet/PuduLauncher/PuduLauncher.csproj +++ b/src-dotnet/PuduLauncher/PuduLauncher.csproj @@ -21,6 +21,7 @@ net10.0 enable + true enable Exe diff --git a/src-dotnet/PuduLauncher/Services/HostWatchdogHostedService.cs b/src-dotnet/PuduLauncher/Services/HostWatchdogHostedService.cs new file mode 100644 index 0000000..cf7d1cb --- /dev/null +++ b/src-dotnet/PuduLauncher/Services/HostWatchdogHostedService.cs @@ -0,0 +1,65 @@ +using PuduLauncher.Services.Interfaces; + +namespace PuduLauncher.Services; + +/// +/// Watches the launcher (host) process. If the host exits without signaling a +/// graceful shutdown (e.g. it crashed), this triggers host shutdown so the TTS +/// cleanup in TtsShutdownHostedService runs. Inert when no --host-pid is given. +/// +public class HostWatchdogHostedService( + IConfiguration configuration, + IProcessExitWatcher watcher, + IHostApplicationLifetime lifetime, + ILogger logger) : IHostedService +{ + private CancellationTokenSource? _cts; + private Task? _watchTask; + + public Task StartAsync(CancellationToken cancellationToken) + { + string? hostPidRaw = configuration["host-pid"]; + if (!int.TryParse(hostPidRaw, out int hostPid)) + { + logger.LogInformation("Host watchdog inert: no valid --host-pid provided"); + return Task.CompletedTask; + } + + _cts = new CancellationTokenSource(); + _watchTask = WatchAsync(hostPid, _cts.Token); + logger.LogInformation("Host watchdog started for PID {Pid}", hostPid); + return Task.CompletedTask; + } + + private async Task WatchAsync(int hostPid, CancellationToken ct) + { + try + { + await watcher.WaitForProcessExitAsync(hostPid, ct); + if (!ct.IsCancellationRequested) + { + logger.LogInformation("Host process {Pid} exited; shutting down sidecar", hostPid); + lifetime.StopApplication(); + } + } + catch (OperationCanceledException) + { + // Watchdog cancelled during normal shutdown. + } + catch (Exception ex) + { + logger.LogWarning(ex, "Host watchdog failed"); + } + } + + public async Task StopAsync(CancellationToken cancellationToken) + { + _cts?.Cancel(); + if (_watchTask is not null) + { + try { await _watchTask; } + catch { /* already logged */ } + } + _cts?.Dispose(); + } +} diff --git a/src-dotnet/PuduLauncher/Services/Interfaces/IProcessExitWatcher.cs b/src-dotnet/PuduLauncher/Services/Interfaces/IProcessExitWatcher.cs new file mode 100644 index 0000000..e2d3656 --- /dev/null +++ b/src-dotnet/PuduLauncher/Services/Interfaces/IProcessExitWatcher.cs @@ -0,0 +1,14 @@ +namespace PuduLauncher.Services.Interfaces; + +/// +/// Abstraction over waiting for an external process (by PID) to exit. +/// Exists so the host watchdog can be unit-tested without a real process. +/// +public interface IProcessExitWatcher +{ + /// + /// Completes when the process with the given id has exited, or returns + /// immediately if no such process exists. Honors cancellation. + /// + Task WaitForProcessExitAsync(int processId, CancellationToken cancellationToken); +} diff --git a/src-dotnet/PuduLauncher/Services/ProcessExitWatcher.cs b/src-dotnet/PuduLauncher/Services/ProcessExitWatcher.cs new file mode 100644 index 0000000..408236b --- /dev/null +++ b/src-dotnet/PuduLauncher/Services/ProcessExitWatcher.cs @@ -0,0 +1,34 @@ +using System.Diagnostics; +using PuduLauncher.Services.Interfaces; + +namespace PuduLauncher.Services; + +public class ProcessExitWatcher : IProcessExitWatcher +{ + public async Task WaitForProcessExitAsync(int processId, CancellationToken cancellationToken) + { + Process process; + try + { + process = Process.GetProcessById(processId); + } + catch (ArgumentException) + { + // Process is not running (or already gone). + return; + } + + using (process) + { + try + { + process.EnableRaisingEvents = true; + await process.WaitForExitAsync(cancellationToken); + } + catch (InvalidOperationException) + { + // Process exited between GetProcessById and the wait. + } + } + } +} diff --git a/src-dotnet/PuduLauncher/Services/TtsServerService.cs b/src-dotnet/PuduLauncher/Services/TtsServerService.cs index 7a3b9b2..ca599c3 100644 --- a/src-dotnet/PuduLauncher/Services/TtsServerService.cs +++ b/src-dotnet/PuduLauncher/Services/TtsServerService.cs @@ -1,4 +1,5 @@ using System.Diagnostics; +using PuduLauncher.Interop; using PuduLauncher.Services.Interfaces; namespace PuduLauncher.Services; @@ -14,6 +15,7 @@ public class TtsServerService( private Process? _serverProcess; private string? _lastInstallPath; + private IntPtr _jobHandle = IntPtr.Zero; public bool IsRunning => _serverProcess is { HasExited: false }; @@ -64,6 +66,8 @@ public Task StartAsync(string installPath, CancellationToken ct = default) _serverProcess = process; logger.LogInformation("TTS server process started (PID {Pid})", process.Id); + AssignToKillOnCloseJob(process); + return Task.CompletedTask; } @@ -108,6 +112,8 @@ public async Task StopAsync(string? installPath = null, CancellationToken ct = d logger.LogInformation("Stopped {Count} lingering HonkTTS process(es) from {Path}", orphanedProcessCount, effectiveInstallPath); } + + CloseJobHandle(); } public async Task WaitForHealthAsync(CancellationToken ct = default) @@ -149,6 +155,7 @@ public void Dispose() } _serverProcess?.Dispose(); + CloseJobHandle(); } private static ProcessStartInfo CreateProcessStartInfo(string scriptPath) @@ -177,6 +184,48 @@ private static ProcessStartInfo CreateProcessStartInfo(string scriptPath) }; } + // Windows defense-in-depth: bind the TTS process tree to a Job Object that + // kills its members when the sidecar (which holds the only job handle) dies, + // even on a hard kill where StopAsync/Dispose never run. + private void AssignToKillOnCloseJob(Process process) + { + if (!OperatingSystem.IsWindows()) + { + return; + } + + try + { + _jobHandle = JobObjects.CreateKillOnCloseJob(); + if (_jobHandle == IntPtr.Zero) + { + logger.LogWarning("Failed to create kill-on-close Job Object for TTS server"); + return; + } + + if (!JobObjects.TryAssignProcess(_jobHandle, process.Handle)) + { + logger.LogWarning("Failed to assign TTS server to Job Object"); + JobObjects.Close(_jobHandle); + _jobHandle = IntPtr.Zero; + } + } + catch (Exception ex) + { + logger.LogWarning(ex, "Error setting up Job Object for TTS server"); + _jobHandle = IntPtr.Zero; + } + } + + private void CloseJobHandle() + { + if (OperatingSystem.IsWindows() && _jobHandle != IntPtr.Zero) + { + JobObjects.Close(_jobHandle); + _jobHandle = IntPtr.Zero; + } + } + private int KillProcessesUnderPath(string installPath) { string normalizedInstallPath; diff --git a/src-dotnet/PuduLauncher/Services/TtsShutdownHostedService.cs b/src-dotnet/PuduLauncher/Services/TtsShutdownHostedService.cs new file mode 100644 index 0000000..8155f3d --- /dev/null +++ b/src-dotnet/PuduLauncher/Services/TtsShutdownHostedService.cs @@ -0,0 +1,31 @@ +using PuduLauncher.Services.Interfaces; + +namespace PuduLauncher.Services; + +/// +/// Ensures the TTS server is stopped when the host shuts down gracefully. +/// The generic host awaits IHostedService.StopAsync during shutdown, so this +/// runs the full process-tree kill and path sweep in TtsServerService.StopAsync. +/// +public class TtsShutdownHostedService( + ITtsServerService serverService, + IPreferencesService preferencesService, + ILogger logger) : IHostedService +{ + public Task StartAsync(CancellationToken cancellationToken) => Task.CompletedTask; + + public async Task StopAsync(CancellationToken cancellationToken) + { + try + { + var prefs = preferencesService.GetPreferences(); + // Pass None: cleanup must complete even though the shutdown token may + // already be cancelling. StopAsync bounds itself with its own grace timeout. + await serverService.StopAsync(prefs.Tts.InstallPath, CancellationToken.None); + } + catch (Exception ex) + { + logger.LogWarning(ex, "Failed to stop TTS server during shutdown"); + } + } +} diff --git a/src-tauri/src/sidecar.rs b/src-tauri/src/sidecar.rs index 32c29ad..f911402 100644 --- a/src-tauri/src/sidecar.rs +++ b/src-tauri/src/sidecar.rs @@ -1,16 +1,24 @@ +use std::sync::Arc; use std::sync::Mutex; use tauri::AppHandle; use tauri_plugin_shell::{process::CommandChild, process::CommandEvent, ShellExt}; +use tokio::sync::Notify; + +/// How long to wait for the sidecar to exit gracefully before hard-killing it. +const GRACEFUL_SHUTDOWN_SECS: u64 = 5; /// Manages the lifecycle of the .NET sidecar process. pub struct SidecarManager { process: Mutex>, + /// Signaled when the sidecar reports it has terminated. + exit_notify: Arc, } impl SidecarManager { pub fn new() -> Self { Self { process: Mutex::new(None), + exit_notify: Arc::new(Notify::new()), } } @@ -21,11 +29,17 @@ impl SidecarManager { let host_exe = std::env::current_exe() .map_err(|e| format!("Failed to resolve host executable path: {}", e))?; + let host_pid = std::process::id().to_string(); let sidecar_command = app .shell() .sidecar("pudu-launcher-sidecar") .map_err(|e| format!("Failed to create sidecar command: {}", e))? - .args(["--host-exe", &host_exe.to_string_lossy()]); + .args([ + "--host-exe", + &host_exe.to_string_lossy(), + "--host-pid", + &host_pid, + ]); let (rx, child) = sidecar_command .spawn() @@ -42,7 +56,7 @@ impl SidecarManager { if let Some(ref mut child) = *self.process.lock().unwrap() { let _ = child.write(b"ACK\n"); } - tokio::spawn(Self::forward_output(rx)); + tokio::spawn(Self::forward_output(rx, self.exit_notify.clone())); log::info!(target: "PuduTauri", "Sidecar started on port {}", port); Ok(port) } @@ -83,7 +97,10 @@ impl SidecarManager { Err("Sidecar output channel closed before port was reported".to_string()) } - async fn forward_output(mut rx: tauri::async_runtime::Receiver) { + async fn forward_output( + mut rx: tauri::async_runtime::Receiver, + exit_notify: Arc, + ) { while let Some(event) = rx.recv().await { match event { CommandEvent::Stdout(line) => { @@ -94,6 +111,9 @@ impl SidecarManager { } CommandEvent::Terminated(payload) => { log::info!(target: "PuduBackend", "Sidecar terminated with code: {:?}", payload.code); + // Wake any graceful-shutdown waiter. notify_one stores a permit + // if no one is waiting yet, so stop() never misses the signal. + exit_notify.notify_one(); break; } _ => {} @@ -101,11 +121,40 @@ impl SidecarManager { } } - /// Stops the sidecar process. + /// Stops the sidecar process gracefully. + /// + /// Sends a SHUTDOWN signal over stdin so the .NET host can stop the TTS + /// server before exiting, waits a bounded time for it to exit, then + /// hard-kills it as a fallback. pub fn stop(&self) { + // Send the graceful shutdown signal while holding the lock briefly. + { + let mut guard = self.process.lock().unwrap(); + match guard.as_mut() { + Some(child) => { + let _ = child.write(b"SHUTDOWN\n"); + } + None => return, + } + } + + // Wait for the sidecar to exit on its own, bounded by the grace period. + let exited_gracefully = tauri::async_runtime::block_on(async { + tokio::time::timeout( + tokio::time::Duration::from_secs(GRACEFUL_SHUTDOWN_SECS), + self.exit_notify.notified(), + ) + .await + .is_ok() + }); + if let Some(child) = self.process.lock().unwrap().take() { - let _ = child.kill(); - log::info!(target: "PuduTauri", "Sidecar stopped"); + if exited_gracefully { + log::info!(target: "PuduTauri", "Sidecar exited gracefully"); + } else { + let _ = child.kill(); + log::warn!(target: "PuduTauri", "Sidecar hard-killed after graceful timeout"); + } } } } From 48c7ffb1815e1154afb0bfcc4f7543d69be2c7df Mon Sep 17 00:00:00 2001 From: Gilles <43683714+corp-0@users.noreply.github.com> Date: Fri, 5 Jun 2026 01:22:38 -0400 Subject: [PATCH 2/4] fix: redirect TTS server stdin to prevent interpreter startup hang --- .../PuduLauncher/Services/TtsService.cs | 48 +++++++++++++++++-- 1 file changed, 45 insertions(+), 3 deletions(-) diff --git a/src-dotnet/PuduLauncher/Services/TtsService.cs b/src-dotnet/PuduLauncher/Services/TtsService.cs index 7caf217..42a5671 100644 --- a/src-dotnet/PuduLauncher/Services/TtsService.cs +++ b/src-dotnet/PuduLauncher/Services/TtsService.cs @@ -80,19 +80,61 @@ public async Task CheckForUpdatesAsync(CancellationToken ct = default) { using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(ct, _shutdownCts.Token); CancellationToken operationCt = linkedCts.Token; - await SetStatusAsync(TtsStatus.CheckingForUpdates); + + if (!await _operationLock.WaitAsync(0, operationCt)) + { + _logger.LogWarning("TTS operation already in progress; skipping update check"); + return; + } + + // Capture the resting status up front so a passive update check (for example + // opening the Preferences tab) never clobbers a running-server state. While + // the server is running we leave _status untouched entirely; otherwise we + // show progress and restore the captured status when the check finishes. + TtsStatus restingStatus = DetermineRestingStatus(); + bool reportProgress = restingStatus != TtsStatus.ServerRunning; try { + if (reportProgress) + { + await SetStatusAsync(TtsStatus.CheckingForUpdates); + } + await _updateChecker.CheckForUpdatesAsync(_manifest?.InstallerVersion, operationCt); - await SetStatusAsync(_manifest != null ? TtsStatus.Installed : TtsStatus.NotInstalled); } catch (Exception ex) { _logger.LogError(ex, "Failed to check for TTS updates"); - await SetStatusAsync(_manifest != null ? TtsStatus.Installed : TtsStatus.NotInstalled); throw; } + finally + { + if (reportProgress) + { + await SetStatusAsync(restingStatus); + } + + _operationLock.Release(); + } + } + + // Determines the status the service should rest at when no operation is in + // flight. Derives the running state from the server process so an update check + // can never report "Installed" while the server is actually alive. + private TtsStatus DetermineRestingStatus() + { + if (_manifest is null) + { + return TtsStatus.NotInstalled; + } + + if (_serverService.IsRunning) + { + return TtsStatus.ServerRunning; + } + + return _status == TtsStatus.ServerStopped ? TtsStatus.ServerStopped : TtsStatus.Installed; } public async Task InstallAsync(CancellationToken ct = default) From d405d88d05b42ab9873b4f0e89ba00e72e8d7f3b Mon Sep 17 00:00:00 2001 From: Gilles <43683714+corp-0@users.noreply.github.com> Date: Fri, 5 Jun 2026 01:23:20 -0400 Subject: [PATCH 3/4] fix: ensure the state of TTS server is synced on frontend --- .../PuduLauncher/Services/TtsServerService.cs | 11 ++++++++++ .../TtsStateContextProvider.tsx | 22 +++++++++++++++++-- 2 files changed, 31 insertions(+), 2 deletions(-) diff --git a/src-dotnet/PuduLauncher/Services/TtsServerService.cs b/src-dotnet/PuduLauncher/Services/TtsServerService.cs index ca599c3..65dd4fb 100644 --- a/src-dotnet/PuduLauncher/Services/TtsServerService.cs +++ b/src-dotnet/PuduLauncher/Services/TtsServerService.cs @@ -63,6 +63,10 @@ public Task StartAsync(string installPath, CancellationToken ct = default) process.Start(); process.BeginOutputReadLine(); process.BeginErrorReadLine(); + // The server never reads stdin; close it so the child sees EOF rather than + // an open idle pipe. The redirect itself is what keeps it off the sidecar's + // inherited stdin (see CreateProcessStartInfo). + process.StandardInput.Close(); _serverProcess = process; logger.LogInformation("TTS server process started (PID {Pid})", process.Id); @@ -170,6 +174,11 @@ private static ProcessStartInfo CreateProcessStartInfo(string scriptPath) UseShellExecute = false, RedirectStandardOutput = true, RedirectStandardError = true, + // Redirect stdin so the server gets a private handle instead of + // inheriting the sidecar's stdin (a pipe the Rust host owns for the + // SHUTDOWN signal). The bundled Python blocks while initializing its + // standard streams on that inherited pipe, hanging before it can run. + RedirectStandardInput = true, }; } @@ -181,6 +190,8 @@ private static ProcessStartInfo CreateProcessStartInfo(string scriptPath) UseShellExecute = false, RedirectStandardOutput = true, RedirectStandardError = true, + // See the Windows branch: keep the server off the sidecar's inherited stdin. + RedirectStandardInput = true, }; } diff --git a/src/contextProviders/TtsStateContextProvider.tsx b/src/contextProviders/TtsStateContextProvider.tsx index d2082ef..b8447da 100644 --- a/src/contextProviders/TtsStateContextProvider.tsx +++ b/src/contextProviders/TtsStateContextProvider.tsx @@ -1,4 +1,4 @@ -import { createContext, type PropsWithChildren, useContext, useEffect, useState } from "react"; +import { createContext, type PropsWithChildren, useContext, useEffect, useRef, useState } from "react"; import { TTS_STATUS } from "../constants/ttsStatus"; import { devBridge } from "../devtools/bridge"; import { EventListener } from "../pudu/events/event-listener"; @@ -25,8 +25,11 @@ export function TtsStateContextProvider(props: PropsWithChildren) { const [statusMessage, setStatusMessage] = useState(null); const [isLoadingState, setIsLoadingState] = useState(true); const [installLogs, setInstallLogs] = useState([]); + const stateLoadedRef = useRef(false); + const loadSeqRef = useRef(0); const loadStatus = async () => { + const seq = ++loadSeqRef.current; const api = new TtsApi(); const result = await api.getStatus(); if (!result.success || !result.data) { @@ -39,7 +42,12 @@ export function TtsStateContextProvider(props: PropsWithChildren) { return null; } - setTtsState(result.data); + // Ignore stale responses when a newer load has been started since (for + // example the bootstrap load racing a load triggered by a status event). + if (seq === loadSeqRef.current) { + setTtsState(result.data); + stateLoadedRef.current = true; + } return result.data; }; @@ -80,6 +88,16 @@ export function TtsStateContextProvider(props: PropsWithChildren) { } setStatusMessage(event.message ?? null); + + // If the full state has not loaded yet, merging onto null would + // silently drop this status change (the cause of a missed startup + // "server is running" notification). Re-fetch instead; the backend + // already reflects the new status. + if (!stateLoadedRef.current) { + void loadStatus(); + return; + } + setTtsState((previous) => { if (previous === null) { return previous; From f99fe452284c8556d81a43b5f3912e7cdd46caed Mon Sep 17 00:00:00 2001 From: Gilles <43683714+corp-0@users.noreply.github.com> Date: Fri, 5 Jun 2026 01:36:33 -0400 Subject: [PATCH 4/4] feat: add new notifications of TTS state change to the frontend --- src/contextProviders/TtsInstallerContextProvider.tsx | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/contextProviders/TtsInstallerContextProvider.tsx b/src/contextProviders/TtsInstallerContextProvider.tsx index 361d9a2..b56041c 100644 --- a/src/contextProviders/TtsInstallerContextProvider.tsx +++ b/src/contextProviders/TtsInstallerContextProvider.tsx @@ -120,10 +120,18 @@ export function TtsInstallerContextProvider(props: PropsWithChildren) { setIsInstallerOpen(true); } + if (status === TTS_STATUS.ServerStarting && prevStatusRef.current !== TTS_STATUS.ServerStarting) { + showInfo({ message: "TTS server is starting..." }); + } + if (status === TTS_STATUS.ServerRunning && prevStatusRef.current !== TTS_STATUS.ServerRunning) { showSuccess({ message: "TTS server is running" }); } + if (status === TTS_STATUS.ServerStopped && prevStatusRef.current !== TTS_STATUS.ServerStopped) { + showInfo({ message: "TTS server stopped" }); + } + setMaxReachedStep((prev) => Math.max(prev, stepFromStatus(status))); prevStatusRef.current = status; }, [status]);