using System.Collections.Concurrent; using System.Diagnostics; using BrassAndSigil.Server.Models; namespace BrassAndSigil.Server.Services; /// /// 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. /// public sealed class ServerProcess : IDisposable { private readonly ServerConfig _config; private Process? _process; private readonly ConcurrentQueue _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? OnLogLine; public event Action? 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 _cpuSamples = new(); private const int CpuSampleWindow = 20; // ~60 s rolling window @ 3 s polling /// /// 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). /// 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; } } } /// /// 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). /// 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; } } } } /// /// BFS the process tree starting from looking for /// a process named like "java". Linux: read /proc/PID/task/PID/children. /// Windows: enumerate parents via Process objects (no extra deps). /// 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(); var queue = new Queue(); 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>(); 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(); list.Add(p); } var visited = new HashSet(); var queue = new Queue(); 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(), 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 args) { args = new List(); 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 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 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); }