From bb0e5298005c330cb822ca7bcc5cba1659bd2ee0 Mon Sep 17 00:00:00 2001 From: Sivert Date: Sat, 17 Oct 2026 20:37:54 +0200 Subject: [PATCH] doctor: check running servers were started with -allow_round_backup_files CS2 writes round backups only when launched with the flag. csm adds it since v1.27.4, but a server started earlier keeps running without it and a round restore then finds no backup. doctor now reads each running server's cs2 /proc//cmdline and, if the flag is missing, says to restart that server (csm restart server-N). Co-Authored-By: Claude Opus 5.5 --- src/internal/csm/doctor_roundbackup.go | 114 ++++++++++++++++++++ src/internal/csm/doctor_roundbackup_test.go | 69 ++++++++++++ src/internal/csm/doctor_run.go | 1 + 3 files changed, 184 insertions(+) create mode 100644 src/internal/csm/doctor_roundbackup.go create mode 100644 src/internal/csm/doctor_roundbackup_test.go diff --git a/src/internal/csm/doctor_roundbackup.go b/src/internal/csm/doctor_roundbackup.go new file mode 100644 index 00000000..477fd4fc --- /dev/null +++ b/src/internal/csm/doctor_roundbackup.go @@ -0,0 +1,114 @@ +package csm + +import ( + "fmt" + "os" + "path/filepath" + "strconv" + "strings" +) + +// checkRoundBackupFlag verifies that every running server's cs2 process was +// started with -allow_round_backup_files. A server started before csm 1.27.4 +// keeps running without it and writes no round backups, so a round restore +// finds nothing. The flag only takes effect at launch, so the fix is a restart. +func checkRoundBackupFlag(meta DoctorMeta, running func(server int) bool) DoctorCheck { + const title = "Running servers have " + roundBackupFlag + if len(meta.Servers) == 0 { + return DoctorCheck{ID: "round-backup-flag", Title: title, Status: DoctorOK, Detail: "No servers to check."} + } + + var missing, ok, unknown []int + for _, n := range meta.Servers { + if !running(n) { + continue + } + dir := filepath.Join(userHomeDir(meta.CS2User), fmt.Sprintf("server-%d", n)) + cmdlines := serverCS2Cmdlines("/proc", dir) + switch { + case len(cmdlines) == 0: + unknown = append(unknown, n) + case anyCmdlineHasFlag(cmdlines, roundBackupFlag): + ok = append(ok, n) + default: + missing = append(missing, n) + } + } + + if len(missing) > 0 { + var restarts []string + for _, n := range missing { + restarts = append(restarts, fmt.Sprintf("csm restart server-%d", n)) + } + return DoctorCheck{ + ID: "round-backup-flag", + Title: title, + Status: DoctorFail, + Detail: fmt.Sprintf("%s running without %s: CS2 writes no round backups, so a round restore finds none.", serverNames(missing), roundBackupFlag), + FixHint: "Restart: " + strings.Join(restarts, ", ") + " (csm adds the flag at launch; wait for the match to end first).", + } + } + if len(ok) == 0 && len(unknown) == 0 { + return DoctorCheck{ID: "round-backup-flag", Title: title, Status: DoctorOK, Detail: "No servers running."} + } + var lines []string + if len(ok) > 0 { + lines = append(lines, fmt.Sprintf("%s started with %s.", serverNames(ok), roundBackupFlag)) + } + status := DoctorOK + if len(unknown) > 0 { + status = DoctorWarn + lines = append(lines, fmt.Sprintf("%s: tmux session is up but no cs2 process found (still starting?); not checked.", serverNames(unknown))) + } + return DoctorCheck{ID: "round-backup-flag", Title: title, Status: status, Detail: strings.Join(lines, "\n")} +} + +func serverNames(ns []int) string { + var parts []string + for _, n := range ns { + parts = append(parts, fmt.Sprintf("server-%d", n)) + } + return strings.Join(parts, ", ") +} + +// serverCS2Cmdlines returns the command line (NUL-separated, as read from +// /proc//cmdline) of every cs2 process whose working directory is under +// serverDir. procRoot is "/proc" (a parameter for tests). +func serverCS2Cmdlines(procRoot, serverDir string) [][]byte { + entries, err := os.ReadDir(procRoot) + if err != nil { + return nil + } + prefix := filepath.Clean(serverDir) + string(os.PathSeparator) + var out [][]byte + for _, e := range entries { + if _, err := strconv.Atoi(e.Name()); err != nil { + continue + } + cwd, err := os.Readlink(filepath.Join(procRoot, e.Name(), "cwd")) + if err != nil || !strings.HasPrefix(filepath.Clean(cwd)+string(os.PathSeparator), prefix) { + continue + } + raw, err := os.ReadFile(filepath.Join(procRoot, e.Name(), "cmdline")) + if err != nil || len(raw) == 0 { + continue + } + argv0 := strings.SplitN(string(raw), "\x00", 2)[0] + if filepath.Base(argv0) != "cs2" { + continue + } + out = append(out, raw) + } + return out +} + +func anyCmdlineHasFlag(cmdlines [][]byte, flag string) bool { + for _, raw := range cmdlines { + for _, arg := range strings.Split(string(raw), "\x00") { + if arg == flag { + return true + } + } + } + return false +} diff --git a/src/internal/csm/doctor_roundbackup_test.go b/src/internal/csm/doctor_roundbackup_test.go new file mode 100644 index 00000000..4e310842 --- /dev/null +++ b/src/internal/csm/doctor_roundbackup_test.go @@ -0,0 +1,69 @@ +package csm + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +// fakeProc adds a process to a fake /proc: its cwd link and NUL-separated cmdline. +func fakeProc(t *testing.T, root, pid, cwd string, argv ...string) { + t.Helper() + dir := filepath.Join(root, pid) + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.Symlink(cwd, filepath.Join(dir, "cwd")); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "cmdline"), []byte(strings.Join(argv, "\x00")+"\x00"), 0o644); err != nil { + t.Fatal(err) + } +} + +func TestServerCS2Cmdlines(t *testing.T) { + root := t.TempDir() + s1 := "/home/cs2/server-1" + fakeProc(t, root, "100", s1+"/game", s1+"/game/bin/linuxsteamrt64/cs2", "-dedicated", "-allow_round_backup_files") + fakeProc(t, root, "101", s1+"/game", "bash", "./cs2.sh") // launcher, not cs2 + fakeProc(t, root, "200", "/home/cs2/server-10/game", "/x/cs2", "-dedicated") + if err := os.MkdirAll(filepath.Join(root, "self"), 0o755); err != nil { + t.Fatal(err) + } + + got := serverCS2Cmdlines(root, s1) + if len(got) != 1 { + t.Fatalf("server-1: got %d cmdlines, want 1 (server-10 must not match server-1)", len(got)) + } + if !anyCmdlineHasFlag(got, roundBackupFlag) { + t.Fatal("flag not found in server-1 cmdline") + } + if n := len(serverCS2Cmdlines(root, "/home/cs2/server-10")); n != 1 { + t.Fatalf("server-10: got %d, want 1", n) + } + if anyCmdlineHasFlag(serverCS2Cmdlines(root, "/home/cs2/server-10"), roundBackupFlag) { + t.Fatal("server-10 has no flag") + } +} + +func TestAnyCmdlineHasFlagExactArg(t *testing.T) { + if anyCmdlineHasFlag([][]byte{[]byte("cs2\x00-allow_round_backup_files_x\x00")}, roundBackupFlag) { + t.Fatal("a longer arg must not count as the flag") + } +} + +func TestCheckRoundBackupFlagNoneRunning(t *testing.T) { + c := checkRoundBackupFlag(DoctorMeta{CS2User: "cs2", Servers: []int{1, 2}}, func(int) bool { return false }) + if c.Status != DoctorOK { + t.Fatalf("status = %s, want OK", c.Status) + } +} + +func TestCheckRoundBackupFlagRunningWithoutProcess(t *testing.T) { + // tmux session up, no cs2 process under the server dir (still starting). + c := checkRoundBackupFlag(DoctorMeta{CS2User: "csm-test-nouser", Servers: []int{1}}, func(int) bool { return true }) + if c.Status != DoctorWarn { + t.Fatalf("status = %s, want WARN", c.Status) + } +} diff --git a/src/internal/csm/doctor_run.go b/src/internal/csm/doctor_run.go index 1e18f6a7..6ff412a0 100644 --- a/src/internal/csm/doctor_run.go +++ b/src/internal/csm/doctor_run.go @@ -86,6 +86,7 @@ func DoctorScan(ctx context.Context, opts DoctorOptions) (DoctorMeta, []DoctorCh checks = append(checks, checkOwnership(meta)) checks = append(checks, checkSteamRT(meta)) checks = append(checks, checkLibV8(meta, opts)) + checks = append(checks, checkRoundBackupFlag(meta, mgr.IsRunning)) return meta, checks, nil }