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
18 changes: 18 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,24 @@ pulls notes from — it's the minimum, the GitHub release page is the maximum.
events don't loop back into the logger), and the failure path on
that path writes to `Console.Error` instead of `Log.Warning` so the
recursion can't restart.
- **Restart Server no longer probes `systemctl` on Windows.** The
`POST /api/server/restart` endpoint always tried
`sudo -n systemctl restart 7daystodie.service` first and waited up to
5 seconds before falling back to in-game shutdown. On Windows there
is no `systemctl`, so every restart click wasted ~5s and logged a
misleading `systemctl restart failed or not available...` warning.
On a live Windows prod box this code path was the trigger for a
cascading crash — the fallback in-game shutdown ran concurrent with
a stuck main-thread operation and snowballed into a stack overflow.
Fix: new `Core/OsRestartStrategy.cs` picks the path per OS. Windows
goes straight to in-game shutdown and relies on the service
supervisor (NSSM `AppExit Restart`, which is its default) to bounce
7DTD on game exit; an INFO-level log line announces the chosen path
instead of the warning. Linux behavior is unchanged — systemctl
first, fall back to in-game shutdown for `Restart=always`. Console
command `krestart` already uses the OS-agnostic
`GracefulRestartFeature.TriggerNow` (no shell-out) so it didn't need
to change.

## [2.8.1] - 2026-05-29

Expand Down
33 changes: 33 additions & 0 deletions src/KitsuneCommand.Tests/Core/OsRestartStrategyTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
using NUnit.Framework;
using KitsuneCommand.Core;

namespace KitsuneCommand.Tests.Core
{
/// <summary>
/// Covers the pure decision function. The platform-detection probe itself
/// (PlatformHelper.IsWindows / DecideForCurrentHost) isn't unit-tested
/// here because it just reads Environment.OSVersion.Platform — the
/// useful test surface is the strategy mapping, which is pure.
/// </summary>
[TestFixture]
public class OsRestartStrategyTests
{
[Test]
public void Decide_OnWindows_SkipsSystemctlProbe()
{
var strategy = OsRestartStrategy.Decide(isWindows: true);

Assert.That(strategy, Is.EqualTo(OsRestartStrategy.Kind.InGameShutdownOnly),
"Windows must skip systemctl — NSSM handles the bounce on game exit.");
}

[Test]
public void Decide_OnLinux_TriesSystemctlFirst()
{
var strategy = OsRestartStrategy.Decide(isWindows: false);

Assert.That(strategy, Is.EqualTo(OsRestartStrategy.Kind.SystemctlThenInGameShutdown),
"Linux must keep trying systemctl first; in-game shutdown is the fallback.");
}
}
}
52 changes: 52 additions & 0 deletions src/KitsuneCommand/Core/OsRestartStrategy.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
namespace KitsuneCommand.Core
{
/// <summary>
/// Picks how the Restart Server endpoint should bounce the 7DTD service for
/// the current host OS. Two paths exist:
///
/// - Linux: try <c>sudo -n systemctl restart 7daystodie.service</c> first
/// (works if <c>scripts/linux-updater/install-linux-updater.sh</c> has
/// been run to add the sudoers entry), then fall back to in-game
/// shutdown and rely on systemd <c>Restart=always</c>.
/// - Windows: there is no <c>systemctl</c>. NSSM (the standard service
/// supervisor for 7DTD on Windows) ships with <c>AppExit Restart</c>
/// as its default, so an in-game shutdown is sufficient — NSSM
/// auto-bounces the service when the game process exits. Probing
/// <c>systemctl</c> on Windows wastes ~5s on every restart click and
/// was the trigger for a cascading crash on a live prod box (the
/// fallback shutdown ran concurrent with a stuck main-thread op and
/// snowballed into a stack overflow).
///
/// Kept as a pure function over <c>isWindows</c> so tests don't need a
/// real OS check or any DI plumbing.
/// </summary>
public static class OsRestartStrategy
{
public enum Kind
{
/// <summary>Try systemctl first, then fall back to in-game shutdown.</summary>
SystemctlThenInGameShutdown,

/// <summary>Skip the systemctl probe entirely; rely on the service
/// supervisor (NSSM <c>AppExit Restart</c>) to bounce the game
/// after the in-game shutdown exits the process.</summary>
InGameShutdownOnly,
}

/// <summary>
/// Pick a strategy based on whether the host is Windows.
/// </summary>
public static Kind Decide(bool isWindows)
{
return isWindows ? Kind.InGameShutdownOnly : Kind.SystemctlThenInGameShutdown;
}

/// <summary>
/// Convenience: decide based on the running host (uses <see cref="PlatformHelper.IsWindows"/>).
/// </summary>
public static Kind DecideForCurrentHost()
{
return Decide(PlatformHelper.IsWindows);
}
}
}
53 changes: 40 additions & 13 deletions src/KitsuneCommand/Web/Controllers/ServerController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -218,13 +218,21 @@ public IHttpActionResult Shutdown([FromBody] ShutdownRequest request)
}

/// <summary>
/// Restart the server. Two-step:
/// 1. Try `sudo -n systemctl restart 7daystodie.service` (non-interactive).
/// Works if install-linux-updater.sh has been run (adds the sudoers entry).
/// 2. If systemctl fails, fall back to in-game shutdown with a short delay.
/// This relies on systemd having `Restart=always` configured to bounce it.
/// Restart the server. OS-aware:
/// - Linux: try `sudo -n systemctl restart 7daystodie.service` first
/// (works if install-linux-updater.sh has been run to add the
/// sudoers entry). If that fails, fall back to in-game shutdown
/// and rely on systemd `Restart=always` to bounce the service.
/// - Windows: skip the systemctl probe entirely — `systemctl`
/// doesn't exist on Windows, so probing it just wastes ~5s and
/// produces a misleading "systemctl failed" warning. Go straight
/// to in-game shutdown; NSSM (the standard Windows service
/// supervisor for 7DTD) ships with `AppExit Restart` as its
/// default and auto-bounces the service when the game exits.
///
/// If neither path works, the server stays down - tell the admin to run the installer.
/// The strategy decision is delegated to <see cref="OsRestartStrategy"/>
/// so it stays testable and shareable with any other restart entry
/// point (e.g. krestart) we add later.
/// </summary>
[HttpPost]
[Route("restart")]
Expand All @@ -236,15 +244,29 @@ public IHttpActionResult Restart([FromBody] RestartRequest request)
if (!System.Text.RegularExpressions.Regex.IsMatch(serviceName, @"^[a-zA-Z0-9._-]+$"))
return BadRequest("Invalid service name.");

// Try systemctl first (Linux path).
if (TryStart("sudo", $"-n systemctl restart {serviceName}", out var stderr, 5000))
var strategy = OsRestartStrategy.DecideForCurrentHost();

if (strategy == OsRestartStrategy.Kind.SystemctlThenInGameShutdown)
{
return Ok(ApiResponse.Ok($"Restart triggered via systemctl ({serviceName}). Server bouncing."));
}
// Try systemctl first (Linux path).
if (TryStart("sudo", $"-n systemctl restart {serviceName}", out var stderr, 5000))
{
return Ok(ApiResponse.Ok($"Restart triggered via systemctl ({serviceName}). Server bouncing."));
}

global::Log.Warning($"[KitsuneCommand] systemctl restart failed or not available ({stderr}). Falling back to in-game shutdown.");
global::Log.Warning($"[KitsuneCommand] systemctl restart failed or not available ({stderr}). Falling back to in-game shutdown.");
}
else
{
// Windows: NSSM (AppExit Restart) handles the bounce after the
// game process exits. INFO log so the operator sees we *chose*
// this path rather than failed into it.
global::Log.Out("[KitsuneCommand] Windows install detected; using in-game shutdown + service-supervisor restart (NSSM AppExit Restart).");
}

// Fallback: in-game shutdown with short delay, rely on systemd Restart=always.
// Common path: in-game shutdown with short delay. On Linux this is
// the fallback after systemctl failed; on Windows it's the only
// path we take.
ModEntry.MainThreadContext.Post(_ =>
{
try
Expand All @@ -253,10 +275,15 @@ public IHttpActionResult Restart([FromBody] RestartRequest request)
}
catch (Exception ex)
{
global::Log.Error($"[KitsuneCommand] Fallback shutdown command failed: {ex.Message}");
global::Log.Error($"[KitsuneCommand] In-game shutdown command failed: {ex.Message}");
}
}, null);

if (strategy == OsRestartStrategy.Kind.InGameShutdownOnly)
{
return Ok(ApiResponse.Ok("Restart requested via in-game shutdown (5s delay). The Windows service supervisor (NSSM) will auto-bounce 7DTD when the game exits, provided AppExit Restart is configured (the NSSM default)."));
}

return Ok(ApiResponse.Ok("Restart requested via in-game shutdown (5s delay). If systemd Restart=always is not set, server will stay down - run scripts/linux-updater/install-linux-updater.sh to configure."));
}

Expand Down
Loading