package runner import ( "bytes" "errors" "fmt" "os" "os/exec" "path/filepath" "strings" "testing" "time" ) // These tests use the REAL systemd user manager. They exist because every property this package // depends on is a property of systemd, not of Go: that a transient unit outlives its spawner, that // ExecStopPost still runs when the kernel kills the process, and that a per-run memory limit is // enforced at all. Reading the manual page proves none of them — and one of them (the limit) is // false at the default placement, which is why Slice is not decoration. func systemdOrSkip(t *testing.T) { t.Helper() if _, err := exec.LookPath("systemd-run"); err != nil { t.Skip("no systemd-run: the transient-unit properties cannot be measured on this host") } // The gate is the CAPABILITY, not a sentence: this used to skip on the words "Failed to connect to // bus" and systemd 259 says "Failed to connect to user scope bus", so on a host without a manager // the tests FAILED instead of skipping — a gate that stopped gating when someone else edited a // string. What is asked instead is the thing these tests need: can this process reach its own user // manager at all. The skip is loud, like the database one, because `make check` names skips and a // silent one reads as coverage. out, err := exec.CommandContext(t.Context(), "systemctl", "--user", "show", "--property=Version").CombinedOutput() if err != nil { t.Skipf("no reachable systemd user manager (%s): the transient-unit properties cannot be measured on this host", bytes.TrimSpace(out)) } } // testUnit names a unit that cannot collide with another run of this same test. // // Not decoration: the unit name is a global of the user's systemd manager, so a fixed name makes two // overlapping test processes — `go test` twice, or a re-run started before the previous unit was // collected — fail with "unit already exists". Reproduced deliberately (two concurrent runs of this // package: one green, one red) before fixing it, because a flake that only appears under a second // runner is exactly the kind that gets blamed on the code under test. func testUnit(t *testing.T) string { t.Helper() return fmt.Sprintf("tm-test-%s-%d", strings.ToLower(strings.TrimPrefix(t.Name(), "Test"))[:8], os.Getpid()) } // marker writer stand-in: the CLI subcommand does exactly this, and pointing the unit at the test // binary would drag the whole test framework into a systemd unit. func markerScript(t *testing.T, dir string) string { t.Helper() path := filepath.Join(dir, "marker.sh") body := `#!/bin/sh printf '{"unit":"%s","result":"%s","code":"%s","status":"%s","at":"2026-08-08T00:00:00Z"}\n' \ "$2" "$SERVICE_RESULT" "$EXIT_CODE" "$EXIT_STATUS" > "$1.tmp" && mv "$1.tmp" "$1" ` if err := os.WriteFile(path, []byte(body), 0o700); err != nil { t.Fatal(err) } return path } func waitForMarker(t *testing.T, path string) Marker { t.Helper() deadline := time.Now().Add(30 * time.Second) for time.Now().Before(deadline) { m, err := ReadMarker(path) if err == nil { return m } if !errors.Is(err, ErrNoMarker) { t.Fatal(err) } time.Sleep(50 * time.Millisecond) } t.Fatalf("no exit marker at %s after 30s", path) return Marker{} } // The engine's exit code has to survive the unit being collected — and it does not survive it in // systemd's own state: with --collect the unit is unloaded and `show` answers ExecMainStatus=0 for a // process that exited 3. The marker is what keeps the fact. func TestAFinishedUnitReportsItsExitCodeThroughTheMarker(t *testing.T) { systemdOrSkip(t) dir := t.TempDir() marker := filepath.Join(dir, "X-1.exit") unit := testUnit(t) r := New(nil) t.Cleanup(func() { _ = r.Stop(t.Context(), unit) }) if err := r.Start(t.Context(), Spec{ Unit: unit, Binary: "/bin/sh", Args: []string{"-c", "exit 3"}, Workdir: dir, ExitMarker: marker, MarkerArgv: []string{markerScript(t, dir), marker, unit}, }); err != nil { t.Fatal(err) } m := waitForMarker(t, marker) code, ok := m.Exited() if !ok || code != 3 { t.Fatalf("marker %+v: Exited() = %d, %v; want 3, true", m, code, ok) } alive, err := r.Alive(t.Context(), unit) if err != nil { t.Fatal(err) } if alive { t.Error("a finished unit reported itself alive") } } // A run outlives the process that started it. This is the whole reason the seam is a transient unit // rather than a child (D39.106): a deploy of the platform must not take a paid translation with it. func TestARunOutlivesTheProcessThatStartedIt(t *testing.T) { systemdOrSkip(t) dir := t.TempDir() marker := filepath.Join(dir, "X-2.exit") unit := testUnit(t) t.Cleanup(func() { _ = New(nil).Stop(t.Context(), unit) }) // A separate process starts the unit and then exits, which is what a platform restart looks like // to the run. script := filepath.Join(dir, "spawn.sh") body := "#!/bin/sh\nexec systemd-run --user --unit=" + unit + " --collect --quiet" + " '--property=Slice=" + Slice + "'" + " '--property=ExecStopPost=" + markerScript(t, dir) + " " + marker + " " + unit + "'" + " -- /bin/sh -c 'sleep 2'\n" if err := os.WriteFile(script, []byte(body), 0o700); err != nil { t.Fatal(err) } if out, err := exec.CommandContext(t.Context(), script).CombinedOutput(); err != nil { t.Fatalf("spawner: %v: %s", err, out) } alive, err := New(nil).Alive(t.Context(), unit) if err != nil { t.Fatal(err) } if !alive { t.Fatal("the unit died with the process that started it") } if m := waitForMarker(t, marker); m.Result != "success" { t.Errorf("marker after a clean run: %+v", m) } } // PD-13's answer, measured rather than declared. ⚠ The slice is what makes it true: in the user // manager's default app.slice the leaf cgroup gets no control files at all, so MemoryMax= is // accepted, reported back by `show`, and enforces nothing. func TestARunIsBoundedByItsOwnCgroup(t *testing.T) { systemdOrSkip(t) dir := t.TempDir() marker := filepath.Join(dir, "X-3.exit") unit := testUnit(t) hog := filepath.Join(dir, "hog.sh") // Faults every page in: an untouched allocation is never charged to the cgroup, so a test that // only allocates passes whether the limit works or not. body := `#!/bin/sh exec /usr/bin/env python3 -c " chunks=[] for i in range(400): b=bytearray(1024*1024) for j in range(0,len(b),4096): b[j]=1 chunks.append(b) " ` if err := os.WriteFile(hog, []byte(body), 0o700); err != nil { t.Fatal(err) } if _, err := exec.LookPath("python3"); err != nil { t.Skip("no python3: the memory limit cannot be exercised on this host") } r := New(nil) t.Cleanup(func() { _ = r.Stop(t.Context(), unit) }) if err := r.Start(t.Context(), Spec{ Unit: unit, Binary: hog, Workdir: dir, MemoryMax: "64M", TasksMax: 32, ExitMarker: marker, MarkerArgv: []string{markerScript(t, dir), marker, unit}, }); err != nil { t.Fatal(err) } m := waitForMarker(t, marker) if m.Result != "oom-kill" { t.Fatalf("a run that touched 400 MiB under MemoryMax=64M ended as %q, want oom-kill: "+ "the limit is not being enforced (check that the leaf cgroup of %s has memory.max)", m.Result, Slice) } }