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..65dd4fb 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 };
@@ -61,9 +63,15 @@ 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);
+ AssignToKillOnCloseJob(process);
+
return Task.CompletedTask;
}
@@ -108,6 +116,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 +159,7 @@ public void Dispose()
}
_serverProcess?.Dispose();
+ CloseJobHandle();
}
private static ProcessStartInfo CreateProcessStartInfo(string scriptPath)
@@ -163,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,
};
}
@@ -174,9 +190,53 @@ 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,
};
}
+ // 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/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)
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