using System.Diagnostics;
using System.Runtime.InteropServices;
using System.Threading.Channels;
namespace NpgsqlRestClient;
///
/// Server watch mode (`--watch` without `--test`): this process becomes a tiny SUPERVISOR that never
/// builds the server itself — it spawns this same executable as a CHILD (marked via an environment
/// variable), watches the SqlFileSource tree and the configuration files, and restarts the child on
/// every debounced change. The child runs the completely normal server pipeline (production parity),
/// with one relaxation: SqlFileSource ErrorMode is forced from Exit to Skip so a broken SQL file logs
/// its error and drops that endpoint instead of killing the server (see App.CreateEndpointSources).
///
/// Design notes:
/// - A child process (vs. in-process rebuild) guarantees clean state: Kestrel's port binding and
/// ASP.NET's route table cannot be torn down in-process (routes can be added, never removed), and
/// static caches would accumulate. This is the same conclusion `dotnet watch` and nodemon reached.
/// The in-process hot-swap upgrade is designed in scrap/WATCH_HOT_SWAP_PLAN.md (target 3.20).
/// - Graceful stop: SIGTERM via libc on Unix (Process.Kill would be SIGKILL), hard kill fallback after
/// a timeout; on Windows there is no SIGTERM for console children — hard process-tree kill, which is
/// acceptable here because a dev server holds nothing that needs teardown.
/// - The child runs a parent WATCHDOG: if the supervisor dies without stopping it (SIGKILL, a wrapper
/// runner like `bun run` murdering the process group), the child exits by itself — no orphan holding
/// the port.
///
public static class WatchSupervisor
{
/// Set on the child process; the value is the supervisor's PID (for the parent watchdog).
public const string ChildEnv = "NPGSQLREST_WATCH_CHILD";
public static bool IsChild { get; } = string.IsNullOrEmpty(Environment.GetEnvironmentVariable(ChildEnv)) is false;
/// Child exit code meaning "the database poller detected a change — respawn me immediately".
public const int DbChangeExitCode = 64;
// Blittable signature — plain DllImport is AOT-safe here and avoids AllowUnsafeBlocks.
[DllImport("libc", SetLastError = true, EntryPoint = "kill")]
private static extern int kill(int pid, int sig);
private const int SIGTERM = 15;
///
/// Called in the CHILD: exit when the supervisor process disappears without stopping us first,
/// so a SIGKILLed supervisor never leaves an orphaned server holding the port.
///
public static void StartParentWatchdog()
{
if (int.TryParse(Environment.GetEnvironmentVariable(ChildEnv), out var parentPid) is false)
{
return;
}
var thread = new Thread(() =>
{
try
{
Process.GetProcessById(parentPid).WaitForExit();
}
catch
{
// already gone
}
Environment.Exit(143);
})
{
IsBackground = true,
Name = "watch-parent-watchdog",
};
thread.Start();
}
/// Supervisor main loop. Returns the process exit code.
public static async Task RunAsync(Config config, string[] args)
{
var output = new Out();
// Something to watch? A SqlFileSource tree, database catalog polling, or both.
var sqlFileCfg = config.NpgsqlRestCfg.GetSection("SqlFileSource");
string? filePattern = null;
if (sqlFileCfg.Exists() && config.GetConfigBool("Enabled", sqlFileCfg, true))
{
filePattern = config.GetConfigStr("FilePattern", sqlFileCfg);
}
// Database polling (the child does the actual polling by hashing the routine discovery query;
// the supervisor needs the value for validation and the banner). With polling active, --watch is
// valid even without a SQL file source — a routines-only project restarts on create/replace/drop/
// comment. Polling is only meaningful when the routine source itself is enabled.
var dbPollInterval = Builder.ParseTestTimeout(
config.GetConfigStr("DatabasePollingInterval", config.Cfg.GetSection("Watch")), TimeSpan.FromSeconds(2));
var routineOptionsCfg = config.NpgsqlRestCfg.GetSection("RoutineOptions");
if (routineOptionsCfg.Exists() && config.GetConfigBool("Enabled", routineOptionsCfg, true) is false)
{
dbPollInterval = TimeSpan.Zero;
}
string? baseDirFull = null;
var skipPattern = "*.test.sql";
if (string.IsNullOrWhiteSpace(filePattern) is false)
{
var baseDir = WatchUtils.GetWatchBaseDir(filePattern);
if (baseDir is null)
{
output.LineAnsi(
$"watch: cannot watch — SqlFileSource FilePattern \"{filePattern}\" has no existing base directory.",
Testing.TestRunner.AnsiFail);
return 1;
}
baseDirFull = Path.GetFullPath(baseDir);
skipPattern = (config.GetConfigStr("SkipPattern", sqlFileCfg) ?? "*.test.sql").Replace('\\', '/');
}
else if (dbPollInterval <= TimeSpan.Zero)
{
output.LineAnsi(
"watch: nothing to watch — --watch without --test requires an enabled SqlFileSource with a FilePattern and/or database polling (Watch:DatabasePollingInterval). For test watch mode use --test --watch.",
Testing.TestRunner.AnsiFail);
return 1;
}
// Config files restart the server too: the .json/.jsonc files passed on the command line, plus
// the implicit default when present.
var configFiles = args
.Where(a => a.StartsWith('-') is false
&& (a.EndsWith(".json", StringComparison.OrdinalIgnoreCase) || a.EndsWith(".jsonc", StringComparison.OrdinalIgnoreCase))
&& File.Exists(a))
.Select(Path.GetFullPath)
.ToList();
if (File.Exists("appsettings.json"))
{
configFiles.Add(Path.GetFullPath("appsettings.json"));
}
configFiles = [.. configFiles.Distinct(StringComparer.Ordinal)];
var configFileSet = new HashSet(configFiles, StringComparer.Ordinal);
using var stop = new CancellationTokenSource();
bool stopping = false;
void OnSignal(PosixSignalContext ctx)
{
// The child receives Ctrl+C from the terminal group by itself; for SIGTERM (docker stop,
// where only PID 1 is signalled) the shutdown path below forwards the stop to the child.
ctx.Cancel = true;
stopping = true;
stop.Cancel();
}
using var sigInt = PosixSignalRegistration.Create(PosixSignal.SIGINT, OnSignal);
using var sigTerm = PosixSignalRegistration.Create(PosixSignal.SIGTERM, OnSignal);
// Watchers (or the polling fallback for bind-mount environments) feed one debounced channel.
var changes = Channel.CreateUnbounded();
var watchers = new List();
bool polling = WatchUtils.UsePollingWatcher;
if (polling)
{
WatchUtils.StartPollingWatcher(baseDirFull ?? "", configFiles, changes.Writer, stop.Token);
}
else
{
if (baseDirFull is not null)
{
watchers.Add(WatchUtils.NewSqlWatcher(baseDirFull, changes.Writer));
}
foreach (var dir in configFiles.Select(Path.GetDirectoryName).Where(d => d is not null).Distinct(StringComparer.Ordinal))
{
var w = new FileSystemWatcher(dir!)
{
NotifyFilter = NotifyFilters.LastWrite | NotifyFilters.FileName | NotifyFilters.Size,
};
void OnFsEvent(object s, FileSystemEventArgs e)
{
if (configFileSet.Contains(e.FullPath))
{
changes.Writer.TryWrite(e.FullPath);
}
}
w.Changed += OnFsEvent;
w.Created += OnFsEvent;
w.Renamed += (s, e) => { if (configFileSet.Contains(e.FullPath)) changes.Writer.TryWrite(e.FullPath); };
w.EnableRaisingEvents = true;
watchers.Add(w);
}
}
var watchingParts = new List(3);
if (baseDirFull is not null)
{
watchingParts.Add($"{Path.GetRelativePath(Environment.CurrentDirectory, baseDirFull)} (*.sql)");
}
if (configFiles.Count > 0)
{
watchingParts.Add("config files");
}
if (dbPollInterval > TimeSpan.Zero)
{
watchingParts.Add($"database (poll {dbPollInterval.TotalSeconds:0.#}s)");
}
var watchingWhat = string.Join(" + ", watchingParts) + (polling ? " [polling]" : "");
output.Line($"watch mode: supervising the server process, watching {watchingWhat} — Ctrl+C to stop", ConsoleColor.Cyan);
Process child = Spawn(args);
Task exitTask = child.WaitForExitAsync(CancellationToken.None);
// The pending batch survives iterations that don't consume it (e.g. a database-change respawn),
// so the channel never has two competing readers.
Task?>? pendingBatch = null;
try
{
while (stopping is false)
{
pendingBatch ??= WatchUtils.NextChangeBatchAsync(changes.Reader, stop.Token);
var completed = await Task.WhenAny(exitTask, pendingBatch);
if (stopping)
{
break;
}
if (completed == exitTask && pendingBatch.IsCompleted is false)
{
if (child.ExitCode == DbChangeExitCode)
{
// The child's database poller detected a routine/annotation change and asked to be
// restarted.
output.Line($"\n— {DateTime.Now:HH:mm:ss} database change detected — restarting —", ConsoleColor.Cyan);
child = Spawn(args);
exitTask = child.WaitForExitAsync(CancellationToken.None);
continue;
}
// The child died on its own (crash, config error, port conflict). Don't respawn in a
// loop — hold until the next file change, then try again.
output.Line($"\nserver exited (code {child.ExitCode}) — waiting for file changes", ConsoleColor.Yellow);
var wakeBatch = await pendingBatch;
pendingBatch = null;
if (wakeBatch is null || stopping)
{
break;
}
if (Relevant(wakeBatch, baseDirFull, skipPattern, configFileSet) is not { } wakeTrigger)
{
// Irrelevant change (e.g. a *.test.sql edit) — keep holding.
continue;
}
output.Line($"— {DateTime.Now:HH:mm:ss} change detected ({wakeTrigger}) — restarting —", ConsoleColor.Cyan);
child = Spawn(args);
exitTask = child.WaitForExitAsync(CancellationToken.None);
continue;
}
var batch = await pendingBatch;
pendingBatch = null;
if (batch is null)
{
break;
}
if (Relevant(batch, baseDirFull, skipPattern, configFileSet) is not { } trigger)
{
continue;
}
output.Line($"\n— {DateTime.Now:HH:mm:ss} change detected ({trigger}) — restarting —", ConsoleColor.Cyan);
StopChild(child);
child = Spawn(args);
exitTask = child.WaitForExitAsync(CancellationToken.None);
}
return 0;
}
finally
{
foreach (var w in watchers)
{
w.Dispose();
}
StopChild(child);
}
}
///
/// First relevant path in the batch (cwd-relative, for display), or null when the whole batch is
/// noise. Relevant: a config file, or a *.sql file under the watched tree that matches the
/// SqlFileSource FilePattern and does NOT match SkipPattern (a test-file edit must not bounce the
/// server).
///
private static string? Relevant(List batch, string? baseDirFull, string skipPattern, HashSet configFileSet)
{
foreach (var path in batch.Distinct(StringComparer.Ordinal))
{
var full = Path.GetFullPath(path);
var rel = Path.GetRelativePath(Environment.CurrentDirectory, full).Replace('\\', '/');
if (configFileSet.Contains(full))
{
return rel;
}
if (baseDirFull is not null
&& full.StartsWith(baseDirFull, StringComparison.Ordinal)
&& rel.EndsWith(".sql", StringComparison.OrdinalIgnoreCase)
&& WatchUtils.MatchesCwdRelativePattern(rel, skipPattern) is false)
{
return rel;
}
}
return null;
}
private static Process Spawn(string[] args)
{
var exe = Environment.ProcessPath ?? "dotnet";
var psi = new ProcessStartInfo(exe)
{
UseShellExecute = false, // stdout/stderr inherited — the child's logs are the console output
};
// Under `dotnet NpgsqlRestClient.dll ...` ProcessPath is the dotnet host; argv[0] is the dll path.
if (string.Equals(Path.GetFileNameWithoutExtension(exe), "dotnet", StringComparison.OrdinalIgnoreCase))
{
psi.ArgumentList.Add(Environment.GetCommandLineArgs()[0]);
}
foreach (var a in args)
{
psi.ArgumentList.Add(a);
}
psi.Environment[ChildEnv] = Environment.ProcessId.ToString();
return Process.Start(psi)!;
}
private static void StopChild(Process child)
{
try
{
if (child.HasExited)
{
return;
}
if (OperatingSystem.IsWindows())
{
// No SIGTERM for console children on Windows; a dev server has nothing needing teardown.
child.Kill(entireProcessTree: true);
}
else
{
kill(child.Id, SIGTERM); // graceful: Kestrel drains and stops
if (child.WaitForExit(5000) is false)
{
child.Kill(entireProcessTree: true);
}
}
child.WaitForExit();
}
catch
{
// already gone
}
}
}