Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
using Microsoft.Extensions.Hosting;

namespace PuduLauncher.Tests.Infrastructure;

/// <summary>Test double for IHostApplicationLifetime that records StopApplication().</summary>
public sealed class RecordingHostApplicationLifetime : IHostApplicationLifetime
{
private readonly TaskCompletionSource _stopRequested =
new(TaskCreationOptions.RunContinuationsAsynchronously);

public bool StopApplicationCalled { get; private set; }

/// <summary>Completes the first time StopApplication() is called.</summary>
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();
}
}
Original file line number Diff line number Diff line change
@@ -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<string, string?>();
if (hostPid is not null) settings["host-pid"] = hostPid;
var configuration = new ConfigurationBuilder().AddInMemoryCollection(settings).Build();

return new HostWatchdogHostedService(
configuration, watcher, lifetime, NullLogger<HostWatchdogHostedService>.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;
}
}
}
}
19 changes: 19 additions & 0 deletions src-dotnet/PuduLauncher.Tests/Services/ProcessExitWatcherTests.cs
Original file line number Diff line number Diff line change
@@ -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;
}
}
Original file line number Diff line number Diff line change
@@ -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<TtsShutdownHostedService>.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<TtsShutdownHostedService>.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;
}
}
29 changes: 29 additions & 0 deletions src-dotnet/PuduLauncher/Host/Setup/AppConfiguration.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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");
}
}
}
112 changes: 112 additions & 0 deletions src-dotnet/PuduLauncher/Interop/JobObjects.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
using System.Runtime.InteropServices;
using System.Runtime.Versioning;

namespace PuduLauncher.Interop;

/// <summary>
/// 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.
/// </summary>
[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);

/// <summary>
/// Creates a job configured to kill all member processes when its last handle
/// closes. Returns IntPtr.Zero on failure.
/// </summary>
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;
}

/// <summary>Assigns a process to the job. Returns false on failure.</summary>
internal static bool TryAssignProcess(IntPtr jobHandle, IntPtr processHandle)
=> AssignProcessToJobObject(jobHandle, processHandle);

/// <summary>Closes a job handle. Closing the last handle kills member processes.</summary>
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;
}
}
3 changes: 3 additions & 0 deletions src-dotnet/PuduLauncher/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,9 @@
builder.Services.AddSingleton<ITtsService, TtsService>();
builder.Services.AddSingleton<IOnboardingService, OnboardingService>();
builder.Services.AddSingleton<IIpcService, IpcService>();
builder.Services.AddSingleton<IProcessExitWatcher, ProcessExitWatcher>();
builder.Services.AddHostedService<TtsShutdownHostedService>();
builder.Services.AddHostedService<HostWatchdogHostedService>();

// ── App ───────────────────────────────────────────
WebApplication app = builder.Build();
Expand Down
1 change: 1 addition & 0 deletions src-dotnet/PuduLauncher/PuduLauncher.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
<ImplicitUsings>enable</ImplicitUsings>
<OutputType>Exe</OutputType>
<!-- Native AOT Configuration -->
Expand Down
Loading
Loading