Initial commit: Brass & Sigil monorepo
Self-hosted Minecraft modpack distribution + administration system.
- launcher/ Avalonia 12 desktop client; single-file win-x64 publish.
Microsoft auth via XboxAuthNet, manifest+SHA-1 mod sync,
portable install path, sidecar settings.
- server/ brass-sigil-server daemon (.NET 8, linux-x64). Wraps the
MC subprocess, embedded Kestrel admin panel with cookie
auth + rate limiting, RCON bridge, scheduled backups,
BlueMap CLI integration with player markers + skin proxy,
friend-side whitelist request flow, world wipe with seed
selection (keep current / random / custom).
- pack/ pack.lock.json (Modrinth + manual CurseForge entries),
data-only tweak source under tweaks/, build outputs in
overrides/ (gitignored).
- scripts/ Build-Pack / Build-Tweaks / Update-Pack / Check-Updates
plus Deploy-Brass.ps1 unified one-shot deploy with
version-bump pre-flight and daemon-state detection.
This commit is contained in:
@@ -0,0 +1,405 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Diagnostics;
|
||||
using BrassAndSigil.Server.Models;
|
||||
|
||||
namespace BrassAndSigil.Server.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Manages the Minecraft Java subprocess: spawns it with the right JVM args,
|
||||
/// captures stdout/stderr into a ring buffer (so the web UI can show recent
|
||||
/// logs), broadcasts new lines via an event, handles graceful shutdown.
|
||||
/// </summary>
|
||||
public sealed class ServerProcess : IDisposable
|
||||
{
|
||||
private readonly ServerConfig _config;
|
||||
private Process? _process;
|
||||
private readonly ConcurrentQueue<LogLine> _logRing = new();
|
||||
private const int LogRingSize = 2000;
|
||||
|
||||
// Process-tree containment: on Windows, the Job Object kills our subprocess
|
||||
// automatically when our own process dies -- regardless of how we died.
|
||||
// Created lazily on first Start() on Windows; destroyed in Dispose.
|
||||
private static WindowsJobObject? _jobObject;
|
||||
|
||||
public event Action<LogLine>? OnLogLine;
|
||||
public event Action<int>? Exited;
|
||||
|
||||
public bool IsRunning => _process is { HasExited: false };
|
||||
public DateTime? StartedAt { get; private set; }
|
||||
public int? Pid => _process?.Id;
|
||||
|
||||
// Memory + CPU sampling. CpuMetrics is a moving average across calls -- the
|
||||
// first call after start returns null because we need two samples for a delta.
|
||||
// We track the Java *descendant* of our shell, not the shell itself, because
|
||||
// run.sh / run.bat is ~2 MB and useless for stats.
|
||||
private TimeSpan _lastCpuTime;
|
||||
private DateTime _lastCpuSampleAt;
|
||||
private int _lastTrackedPid;
|
||||
private Process? _trackedJava;
|
||||
private readonly object _statsLock = new();
|
||||
private readonly Queue<double> _cpuSamples = new();
|
||||
private const int CpuSampleWindow = 20; // ~60 s rolling window @ 3 s polling
|
||||
|
||||
/// <summary>
|
||||
/// Returns the Java JVM descendant if found, otherwise falls back to the
|
||||
/// directly-spawned shell process. Cached and re-resolved when the cached
|
||||
/// pid exits (e.g., MC restart).
|
||||
/// </summary>
|
||||
private Process? TrackedProcess
|
||||
{
|
||||
get
|
||||
{
|
||||
if (_process is null || _process.HasExited) return null;
|
||||
if (_trackedJava is { HasExited: false }) return _trackedJava;
|
||||
// Try to find a 'java' descendant of our shell. If not found yet (still
|
||||
// booting), fall back to the shell -- first stats will read tiny, then
|
||||
// reset on next call once Java is up.
|
||||
var found = FindJavaDescendant(_process.Id);
|
||||
_trackedJava = found;
|
||||
return found ?? _process;
|
||||
}
|
||||
}
|
||||
|
||||
public long? MemoryBytes
|
||||
{
|
||||
get
|
||||
{
|
||||
var p = TrackedProcess;
|
||||
if (p is null || p.HasExited) return null;
|
||||
try { p.Refresh(); return p.WorkingSet64; }
|
||||
catch { return null; }
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// System-wide CPU percentage (0-100) plus rolling-window peak and average.
|
||||
/// Each call samples once and contributes to a 20-entry history (~60 s at 3 s
|
||||
/// polling). First call after start returns null (need two samples for a delta).
|
||||
/// </summary>
|
||||
public (double Current, double Max, double Avg)? CpuMetrics
|
||||
{
|
||||
get
|
||||
{
|
||||
var p = TrackedProcess;
|
||||
if (p is null || p.HasExited) return null;
|
||||
lock (_statsLock)
|
||||
{
|
||||
try
|
||||
{
|
||||
p.Refresh();
|
||||
var now = DateTime.UtcNow;
|
||||
var cpuNow = p.TotalProcessorTime;
|
||||
if (_lastTrackedPid != p.Id || _lastCpuSampleAt == default)
|
||||
{
|
||||
_lastTrackedPid = p.Id;
|
||||
_lastCpuTime = cpuNow;
|
||||
_lastCpuSampleAt = now;
|
||||
_cpuSamples.Clear();
|
||||
return null;
|
||||
}
|
||||
var elapsedReal = (now - _lastCpuSampleAt).TotalMilliseconds;
|
||||
var elapsedCpu = (cpuNow - _lastCpuTime).TotalMilliseconds;
|
||||
_lastCpuTime = cpuNow;
|
||||
_lastCpuSampleAt = now;
|
||||
if (elapsedReal <= 0) return (0, 0, 0);
|
||||
|
||||
// System-wide: divide by core count so the value is bounded 0-100
|
||||
// and intuitive ("42% of total CPU capacity"). The user found the
|
||||
// top-style 0-N*100 range confusing for a fleet view.
|
||||
var perCore = elapsedCpu / elapsedReal * 100.0;
|
||||
var systemWide = Math.Min(100.0, perCore / Environment.ProcessorCount);
|
||||
|
||||
_cpuSamples.Enqueue(systemWide);
|
||||
while (_cpuSamples.Count > CpuSampleWindow) _cpuSamples.Dequeue();
|
||||
|
||||
return (systemWide, _cpuSamples.Max(), _cpuSamples.Average());
|
||||
}
|
||||
catch { return null; }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// BFS the process tree starting from <paramref name="rootPid"/> looking for
|
||||
/// a process named like "java". Linux: read /proc/PID/task/PID/children.
|
||||
/// Windows: enumerate parents via Process objects (no extra deps).
|
||||
/// </summary>
|
||||
private static Process? FindJavaDescendant(int rootPid)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (OperatingSystem.IsLinux())
|
||||
{
|
||||
return FindJavaDescendantLinux(rootPid);
|
||||
}
|
||||
if (OperatingSystem.IsWindows())
|
||||
{
|
||||
return FindJavaDescendantWindows(rootPid);
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
return null;
|
||||
}
|
||||
|
||||
private static Process? FindJavaDescendantLinux(int rootPid)
|
||||
{
|
||||
var visited = new HashSet<int>();
|
||||
var queue = new Queue<int>();
|
||||
queue.Enqueue(rootPid);
|
||||
while (queue.Count > 0)
|
||||
{
|
||||
var pid = queue.Dequeue();
|
||||
if (!visited.Add(pid)) continue;
|
||||
var childrenPath = $"/proc/{pid}/task/{pid}/children";
|
||||
if (!File.Exists(childrenPath)) continue;
|
||||
string raw;
|
||||
try { raw = File.ReadAllText(childrenPath); } catch { continue; }
|
||||
foreach (var token in raw.Split(' ', StringSplitOptions.RemoveEmptyEntries))
|
||||
{
|
||||
if (!int.TryParse(token, out var cpid)) continue;
|
||||
Process? p = null;
|
||||
try { p = Process.GetProcessById(cpid); } catch { continue; }
|
||||
if (p.ProcessName.Equals("java", StringComparison.OrdinalIgnoreCase))
|
||||
return p;
|
||||
queue.Enqueue(cpid);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static Process? FindJavaDescendantWindows(int rootPid)
|
||||
{
|
||||
// Build a parent->children map by reading every running process's parent
|
||||
// PID once, then BFS. Slower than Linux's /proc but works without WMI.
|
||||
var allProcs = Process.GetProcesses();
|
||||
var byParent = new Dictionary<int, List<Process>>();
|
||||
foreach (var p in allProcs)
|
||||
{
|
||||
int parent;
|
||||
try { parent = GetParentPidWindows(p); } catch { continue; }
|
||||
if (parent == 0) continue;
|
||||
if (!byParent.TryGetValue(parent, out var list)) byParent[parent] = list = new List<Process>();
|
||||
list.Add(p);
|
||||
}
|
||||
var visited = new HashSet<int>();
|
||||
var queue = new Queue<int>();
|
||||
queue.Enqueue(rootPid);
|
||||
while (queue.Count > 0)
|
||||
{
|
||||
var pid = queue.Dequeue();
|
||||
if (!visited.Add(pid)) continue;
|
||||
if (!byParent.TryGetValue(pid, out var children)) continue;
|
||||
foreach (var c in children)
|
||||
{
|
||||
if (c.ProcessName.Equals("java", StringComparison.OrdinalIgnoreCase))
|
||||
return c;
|
||||
queue.Enqueue(c.Id);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
[System.Runtime.InteropServices.DllImport("ntdll.dll")]
|
||||
private static extern int NtQueryInformationProcess(
|
||||
IntPtr processHandle, int processInformationClass,
|
||||
ref ProcessBasicInformation processInformation, int processInformationLength, out int returnLength);
|
||||
|
||||
[System.Runtime.InteropServices.StructLayout(System.Runtime.InteropServices.LayoutKind.Sequential)]
|
||||
private struct ProcessBasicInformation
|
||||
{
|
||||
public IntPtr Reserved1;
|
||||
public IntPtr PebBaseAddress;
|
||||
public IntPtr Reserved2_0;
|
||||
public IntPtr Reserved2_1;
|
||||
public IntPtr UniqueProcessId;
|
||||
public IntPtr InheritedFromUniqueProcessId; // <-- parent PID
|
||||
}
|
||||
|
||||
private static int GetParentPidWindows(Process p)
|
||||
{
|
||||
var info = new ProcessBasicInformation();
|
||||
int rc = NtQueryInformationProcess(p.Handle, 0, ref info,
|
||||
System.Runtime.InteropServices.Marshal.SizeOf<ProcessBasicInformation>(), out _);
|
||||
return rc != 0 ? 0 : info.InheritedFromUniqueProcessId.ToInt32();
|
||||
}
|
||||
|
||||
public ServerProcess(ServerConfig config) => _config = config;
|
||||
|
||||
public bool Start()
|
||||
{
|
||||
if (IsRunning) return false;
|
||||
|
||||
// Reset cached Java descendant + CPU sampling baseline so the next call
|
||||
// re-resolves once the new run.sh -> java tree is up.
|
||||
_trackedJava = null;
|
||||
_lastTrackedPid = 0;
|
||||
_lastCpuSampleAt = default;
|
||||
|
||||
// The mod loader's start script lives next to the server jar. NeoForge produces
|
||||
// run.bat / run.sh from its installer; CmlLib's NeoForgeInstaller doesn't run on
|
||||
// the server side, so we build the equivalent JVM command ourselves.
|
||||
var startScript = ResolveStartCommand(out var argList);
|
||||
var startInfo = new ProcessStartInfo
|
||||
{
|
||||
FileName = startScript,
|
||||
WorkingDirectory = Path.GetFullPath(_config.ServerDir),
|
||||
UseShellExecute = false,
|
||||
RedirectStandardInput = true,
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
CreateNoWindow = true,
|
||||
};
|
||||
foreach (var a in argList) startInfo.ArgumentList.Add(a);
|
||||
|
||||
// CRITICAL: NeoForge's run.bat / run.sh just calls plain `java`, which resolves to
|
||||
// whatever's on PATH. If we configured a specific Java (or auto-downloaded one),
|
||||
// prepend its bin dir + set JAVA_HOME so the script picks up the right JVM
|
||||
// instead of an older system Java.
|
||||
ApplyJavaEnvironment(startInfo);
|
||||
|
||||
_process = new Process { StartInfo = startInfo, EnableRaisingEvents = true };
|
||||
_process.OutputDataReceived += (_, e) => OnLine(e.Data, isError: false);
|
||||
_process.ErrorDataReceived += (_, e) => OnLine(e.Data, isError: true);
|
||||
_process.Exited += (_, _) =>
|
||||
{
|
||||
var code = _process?.ExitCode ?? -1;
|
||||
OnLine($"=== Server exited (code {code}) ===", isError: false);
|
||||
Exited?.Invoke(code);
|
||||
};
|
||||
|
||||
_process.Start();
|
||||
|
||||
// Bind the Java subprocess to a Job Object on Windows so it gets killed
|
||||
// automatically if we exit ungracefully (X-button, Task Manager, etc.).
|
||||
if (OperatingSystem.IsWindows())
|
||||
{
|
||||
try
|
||||
{
|
||||
_jobObject ??= new WindowsJobObject();
|
||||
_jobObject.AssignProcess(_process);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Non-fatal -- we still try to clean up via Process.Kill in Dispose.
|
||||
OnLine($"[brass-sigil-server] Couldn't attach Job Object: {ex.Message}", isError: true);
|
||||
}
|
||||
}
|
||||
|
||||
_process.BeginOutputReadLine();
|
||||
_process.BeginErrorReadLine();
|
||||
StartedAt = DateTime.UtcNow;
|
||||
return true;
|
||||
}
|
||||
|
||||
private string ResolveStartCommand(out List<string> args)
|
||||
{
|
||||
args = new List<string>();
|
||||
var dir = Path.GetFullPath(_config.ServerDir);
|
||||
|
||||
// Prefer NeoForge's generated run.sh / run.bat -- they include the right module-path JVM args.
|
||||
var runShell = OperatingSystem.IsWindows() ? "run.bat" : "run.sh";
|
||||
var runScript = Path.Combine(dir, runShell);
|
||||
if (File.Exists(runScript))
|
||||
{
|
||||
// Make sure the user_jvm_args.txt has the memory we want
|
||||
EnsureUserJvmArgs(dir);
|
||||
// Suppress the Swing server-GUI window -- we use the web panel instead.
|
||||
// NeoForge's run.bat / run.sh forwards extra args to Minecraft.
|
||||
args.Add("nogui");
|
||||
return runScript;
|
||||
}
|
||||
|
||||
// Fallback: invoke java directly on the server jar. This won't work for NeoForge
|
||||
// (it requires the loader's run script), but it works for vanilla and Forge legacy.
|
||||
args.Add($"-Xms{_config.MemoryMB}M");
|
||||
args.Add($"-Xmx{_config.MemoryMB}M");
|
||||
args.Add("-jar");
|
||||
args.Add("server.jar");
|
||||
args.Add("nogui");
|
||||
return _config.JavaPath;
|
||||
}
|
||||
|
||||
private void ApplyJavaEnvironment(ProcessStartInfo psi)
|
||||
{
|
||||
// Only meaningful when we have an absolute path to a specific java binary
|
||||
// (i.e., not just "java" on PATH).
|
||||
if (string.IsNullOrEmpty(_config.JavaPath)) return;
|
||||
if (!Path.IsPathRooted(_config.JavaPath)) return;
|
||||
if (!File.Exists(_config.JavaPath)) return;
|
||||
|
||||
var javaBinDir = Path.GetDirectoryName(_config.JavaPath);
|
||||
if (string.IsNullOrEmpty(javaBinDir)) return;
|
||||
var javaHome = Path.GetDirectoryName(javaBinDir); // parent of bin/
|
||||
|
||||
var sep = OperatingSystem.IsWindows() ? ";" : ":";
|
||||
var existingPath = Environment.GetEnvironmentVariable("PATH") ?? "";
|
||||
psi.Environment["PATH"] = $"{javaBinDir}{sep}{existingPath}";
|
||||
if (!string.IsNullOrEmpty(javaHome))
|
||||
{
|
||||
psi.Environment["JAVA_HOME"] = javaHome;
|
||||
}
|
||||
}
|
||||
|
||||
private void EnsureUserJvmArgs(string dir)
|
||||
{
|
||||
// NeoForge's run.bat / run.sh reads this file for JVM-level args.
|
||||
// Generational ZGC: concurrent low-pause GC, recommended by Distant Horizons
|
||||
// and significantly better than G1 for heavily-modded MC. Requires Java 21+.
|
||||
var path = Path.Combine(dir, "user_jvm_args.txt");
|
||||
var content =
|
||||
$"-Xms{_config.MemoryMB}M\n" +
|
||||
$"-Xmx{_config.MemoryMB}M\n" +
|
||||
"-XX:+UseZGC\n" +
|
||||
"-XX:+ZGenerational\n";
|
||||
try { File.WriteAllText(path, content); } catch { /* best-effort */ }
|
||||
}
|
||||
|
||||
private void OnLine(string? data, bool isError)
|
||||
{
|
||||
if (string.IsNullOrEmpty(data)) return;
|
||||
var line = new LogLine(DateTimeOffset.UtcNow, isError, data);
|
||||
_logRing.Enqueue(line);
|
||||
while (_logRing.Count > LogRingSize) _logRing.TryDequeue(out _);
|
||||
OnLogLine?.Invoke(line);
|
||||
}
|
||||
|
||||
public IReadOnlyList<LogLine> RecentLogs() => _logRing.ToArray();
|
||||
|
||||
public async Task SendInputAsync(string command, CancellationToken ct = default)
|
||||
{
|
||||
if (_process is null || _process.HasExited) return;
|
||||
await _process.StandardInput.WriteLineAsync(command.AsMemory(), ct);
|
||||
await _process.StandardInput.FlushAsync(ct);
|
||||
}
|
||||
|
||||
public async Task<bool> StopAsync(TimeSpan? graceful = null, CancellationToken ct = default)
|
||||
{
|
||||
if (!IsRunning) return false;
|
||||
graceful ??= TimeSpan.FromSeconds(30);
|
||||
try
|
||||
{
|
||||
await SendInputAsync("stop", ct);
|
||||
using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||
cts.CancelAfter(graceful.Value);
|
||||
try { await _process!.WaitForExitAsync(cts.Token); return true; }
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
_process?.Kill(entireProcessTree: true);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
try { _process?.Kill(entireProcessTree: true); } catch { }
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
try { _process?.Kill(entireProcessTree: true); } catch { }
|
||||
_process?.Dispose();
|
||||
}
|
||||
|
||||
public sealed record LogLine(DateTimeOffset At, bool IsError, string Text);
|
||||
}
|
||||
Reference in New Issue
Block a user