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
104 changes: 104 additions & 0 deletions cli/SimpleModule.Cli/Commands/Dev/DevCommand.Helpers.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
using System.Diagnostics;

namespace SimpleModule.Cli.Commands.Dev;

public sealed partial class DevCommand
{
private static int GetSafePid(Process process)
{
try
{
return process.Id;
}
#pragma warning disable CA1031 // Do not catch general exception types
catch
{
return -1;
}
#pragma warning restore CA1031
}

/// <summary>
/// Parse launchSettings.json to find the ports ASP.NET will bind to.
/// Falls back to the default ports (5001, 5000) if the file can't be read.
/// </summary>
private static List<int> DiscoverDotnetPorts(string hostProjectPath)
{
var ports = new List<int>();
var hostDir = Path.GetDirectoryName(hostProjectPath);
if (hostDir is null)
{
return [5001, 5000];
}

var launchSettingsPath = Path.Combine(hostDir, "Properties", "launchSettings.json");

if (!File.Exists(launchSettingsPath))
{
return [5001, 5000];
}

try
{
var json = File.ReadAllText(launchSettingsPath);

// Extract applicationUrl values and parse ports from them.
// Format: "applicationUrl": "https://localhost:5001;http://localhost:5000"
// Use simple string parsing to avoid adding a JSON dependency to the CLI.
var searchKey = "\"applicationUrl\"";
var idx = json.IndexOf(searchKey, StringComparison.OrdinalIgnoreCase);
while (idx >= 0)
{
var colonIdx = json.IndexOf(':', idx + searchKey.Length);
if (colonIdx < 0)
{
break;
}

var quoteStart = json.IndexOf('"', colonIdx + 1);
if (quoteStart < 0)
{
break;
}

var quoteEnd = json.IndexOf('"', quoteStart + 1);
if (quoteEnd < 0)
{
break;
}

var urlValue = json[(quoteStart + 1)..quoteEnd];
foreach (var url in urlValue.Split(';'))
{
// Extract port from URL like "https://localhost:5001"
var lastColon = url.LastIndexOf(':');
if (
lastColon >= 0
&& int.TryParse(
url[(lastColon + 1)..],
System.Globalization.NumberStyles.Integer,
System.Globalization.CultureInfo.InvariantCulture,
out var port
)
)
{
if (!ports.Contains(port))
{
ports.Add(port);
}
}
}

idx = json.IndexOf(searchKey, quoteEnd + 1, StringComparison.OrdinalIgnoreCase);
}
}
#pragma warning disable CA1031 // Do not catch general exception types
catch
{
// If we can't parse, fall back to defaults
}
#pragma warning restore CA1031

return ports.Count > 0 ? ports : [5001, 5000];
}
}
221 changes: 221 additions & 0 deletions cli/SimpleModule.Cli/Commands/Dev/DevCommand.Shutdown.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,221 @@
using Spectre.Console;

namespace SimpleModule.Cli.Commands.Dev;

public sealed partial class DevCommand
{
/// <summary>
/// Graceful shutdown: send termination signals to children, wait for them to exit,
/// then force-kill any stragglers.
/// </summary>
private void GracefulShutdown()
{
// Transition: running → graceful
if (
Interlocked.CompareExchange(
ref _shutdownState,
ShutdownPhase.Graceful,
ShutdownPhase.Running
) != ShutdownPhase.Running
)
{
return;
}

AnsiConsole.MarkupLine("");
AnsiConsole.MarkupLine("[cyan]Stopping all processes gracefully...[/]");

// Phase 1: Send graceful termination signal to the entire process tree
foreach (var (process, label) in _processes)
{
SendTermSignal(process, label);
}

// Phase 2: Wait for graceful exit
if (WaitAllExit(GracefulShutdownTimeoutMs))
{
AnsiConsole.MarkupLine("[green]All processes stopped.[/]");
return;
}

// Phase 3: Force-kill survivors
AnsiConsole.MarkupLine(
"[yellow]Some processes did not exit gracefully. Force-killing...[/]"
);
ForceKillAll();

if (!WaitAllExit(ForceKillTimeoutMs))
{
AnsiConsole.MarkupLine(
"[red]Warning: Some processes may still be running. Check manually.[/]"
);
LogSurvivorPids();
}
else
{
AnsiConsole.MarkupLine("[green]All processes stopped.[/]");
}
}

/// <summary>
/// Force-kill all processes and their entire process trees.
/// Uses .NET's cross-platform <c>Kill(entireProcessTree: true)</c> which
/// walks /proc on Linux, libproc on macOS, and NtQuerySystemInformation on Windows.
/// </summary>
private void ForceKillAll()
{
// Transition to force state (from any state)
Interlocked.Exchange(ref _shutdownState, ShutdownPhase.Force);

foreach (var (process, label) in _processes)
{
try
{
if (!process.HasExited)
{
process.Kill(entireProcessTree: true);
}
}
#pragma warning disable CA1031 // Do not catch general exception types
catch (Exception ex)
#pragma warning restore CA1031
{
// Kill can fail if process exited between check and kill,
// or if we lack permissions for a child process.
// Fall back to killing just the direct process.
AnsiConsole.MarkupLine(
$"[dim][[{label}]][/] [dim]Tree kill failed (PID {GetSafePid(process)}): {ex.Message}[/]"
);
try
{
if (!process.HasExited)
{
process.Kill(entireProcessTree: false);
}
}
#pragma warning disable CA1031 // Do not catch general exception types
catch
{
// Truly unreachable
}
#pragma warning restore CA1031
}
}
}

/// <summary>
/// Wait for all tracked processes to exit within the given timeout.
/// Returns true if all exited, false if any are still alive.
/// </summary>
private bool WaitAllExit(int timeoutMs)
{
var deadline = Environment.TickCount64 + timeoutMs;

foreach (var (process, _) in _processes)
{
var remaining = (int)(deadline - Environment.TickCount64);
if (remaining <= 0)
{
return AllExited();
}

try
{
process.WaitForExit(remaining);
}
#pragma warning disable CA1031 // Do not catch general exception types
catch
{
// Process may already be disposed
}
#pragma warning restore CA1031
}

return AllExited();
}

private bool AllExited()
{
foreach (var (process, _) in _processes)
{
try
{
if (!process.HasExited)
{
return false;
}
}
#pragma warning disable CA1031 // Do not catch general exception types
catch
{
// Process disposed — treat as exited
}
#pragma warning restore CA1031
}

return true;
}

private void LogSurvivorPids()
{
foreach (var (process, label) in _processes)
{
try
{
if (!process.HasExited)
{
AnsiConsole.MarkupLine($"[red][[{label}]][/] Still running: PID {process.Id}");
}
}
#pragma warning disable CA1031 // Do not catch general exception types
catch
{
// Ignore
}
#pragma warning restore CA1031
}
}

private void DisposeAll()
{
foreach (var (process, _) in _processes)
{
try
{
process.Dispose();
}
#pragma warning disable CA1031 // Do not catch general exception types
catch
{
// Best-effort dispose
}
#pragma warning restore CA1031
}

_processes.Clear();
}

private void OnCancelKeyPress(object? sender, ConsoleCancelEventArgs e)
{
if (_shutdownState == ShutdownPhase.Running)
{
// First Ctrl+C: graceful shutdown, cancel the default termination
e.Cancel = true;
GracefulShutdown();
}
else
{
// Second Ctrl+C: force-kill immediately, let process terminate
AnsiConsole.MarkupLine("[red]Force-killing all processes...[/]");
ForceKillAll();
e.Cancel = false;
}
}

private void OnProcessExit(object? sender, EventArgs e)
{
// Process is exiting (terminal closed, kill signal, etc.)
// Force-kill children to prevent orphans
ForceKillAll();
}
}
Loading
Loading