A user unit waits for its account's manager, and lingering is the account's (hq ADR 0177)

An account's manager runs only while it is logged in or lingers. A user-scoped unit
whose manager is not running is now "waiting" rather than failed, its record kept as
it was; its removal is never fatal (kept recorded, retried) and an account that is
gone is forgotten. Whether the manager runs is asked of user@<uid>.service in the
machine's manager: asking the account's own, through --machine, logs it in.

The user shape gains `linger`, set with loginctl, read back from logind's record,
and given back on removal like the shell. Unit files the mesh writes under
~/.config/systemd/user or /etc/systemd/user make that unit the mesh's, and made,
holds and found units are keyed by manager and name, so an account's unit and the
machine's of one name are two units. A service moved between managers gives the old
one back through the manager it was in. OpenRC refuses both.
This commit is contained in:
jochen
2026-10-04 12:41:32 +02:00
parent 84540e709a
commit d5c365cb2b
13 changed files with 1148 additions and 83 deletions
+86 -1
View File
@@ -2,6 +2,7 @@ package system
import (
"context"
"errors"
"fmt"
"os"
"path/filepath"
@@ -186,7 +187,7 @@ func describeAge(when, now time.Time) string {
// so LoadState is what is read — and it is the thing an interface spanning systemd and OpenRC
// would have had to drop.
func (arch) ServiceState(ctx context.Context, run Runner, unit string) (string, error) {
out, _ := run(ctx, "systemctl", "show", unit,
out, asked := run(ctx, "systemctl", "show", unit,
"--property=LoadState", "--property=ActiveState", "--property=Type",
"--property=RemainAfterExit", "--property=ExecMainStatus")
@@ -212,6 +213,11 @@ func (arch) ServiceState(ctx context.Context, run Runner, unit string) (string,
switch load {
case "":
// With why, where it said why: an account's manager that could not be reached says so
// here, and nowhere else (novox/hq ADR 0177).
if asked != nil {
return "", fmt.Errorf("the service manager said nothing about %s: %w", unit, asked)
}
return "", fmt.Errorf("the service manager said nothing about %s", unit)
case "not-found":
return "", fmt.Errorf(
@@ -364,3 +370,82 @@ func (arch) ReloadService(ctx context.Context, run Runner, unit string) error {
_, err := run(ctx, "systemctl", "reload", unit)
return err
}
// An account's own service manager (novox/hq ADR 0177).
//
// systemd runs one manager per account beside the machine's, as user@<uid>.service, from the
// account's first login until its last logout — or for as long as the machine runs, when the
// account lingers. A user-scoped unit lives in that manager, so it can be acted on only while the
// manager runs, and lingering is what makes it run with nobody logged in.
// lingerDir is where systemd-logind keeps which accounts linger: one empty file per account. A
// variable so a test can give it a directory of its own; LingerIn is how.
var lingerDir = "/var/lib/systemd/linger"
// LingerIn points where the machine keeps lingering at a directory a test owns, until the returned
// function puts it back. Nothing outside a test calls it.
func LingerIn(dir string) (restore func()) {
was := lingerDir
lingerDir = dir
return func() { lingerDir = was }
}
// Lingering is whether an account's manager is kept running with nobody logged in. Read from
// logind's own record rather than from `loginctl show-user`, which answers only for an account
// that is logged in or already lingers — absence there is an error, and absence here is the answer.
func (arch) Lingering(_ context.Context, _ Runner, name string) (bool, error) {
_, err := os.Stat(filepath.Join(lingerDir, name))
if err == nil {
return true, nil
}
if os.IsNotExist(err) {
return false, nil
}
return false, fmt.Errorf("whether %q lingers could not be read: %w", name, err)
}
// SetLingering has logind keep an account's manager running with nobody logged in, or stop doing
// so. Disabling it stops the manager of an account that is not logged in, and every unit in it.
func (arch) SetLingering(ctx context.Context, run Runner, name string, on bool) error {
verb := "enable-linger"
if !on {
verb = "disable-linger"
}
if _, err := run(ctx, "loginctl", verb, name); err != nil {
return fmt.Errorf("cannot %s for %q: %w", verb, name, err)
}
return nil
}
// UserManagerRunning is whether an account's own manager runs now, asked of the machine's manager
// — never of the account's.
//
// **Asking the account's manager would start it.** `systemctl --user --machine=<account>@` reaches
// it through `systemd-run --machine=<account>@.host -p PAMName=login systemd-stdio-bridge`: a login,
// which starts the account's manager if none runs, for as long as that one command takes. The host
// would then act on a manager that ends a moment later and take the account's whole session with it
// — a unit started into it stops again, and the next apply starts it again. So whether there is a
// manager is read from user@<uid>.service in the machine's own, which a query does not start.
//
// exists is false for an account the machine does not have.
func (arch) UserManagerRunning(ctx context.Context, run Runner, account string) (running, exists bool, err error) {
login, exists, err := LookUpUser(ctx, run, account)
if err != nil || !exists {
return false, exists, err
}
if login.UID == "" {
return false, true, fmt.Errorf("the user database gave %q no number", account)
}
// is-active exits non-zero for every answer but "active", so the words are the answer and the
// exit is not; no words at all is a manager that did not answer.
out, err := run(ctx, "systemctl", "is-active", "user@"+login.UID+".service")
state := strings.TrimSpace(out)
if state == "" {
if err == nil {
err = errors.New("no answer")
}
return false, true, fmt.Errorf("the service manager did not say whether %q's own manager runs: %w",
account, err)
}
return state == "active", true, nil
}
+4 -1
View File
@@ -84,6 +84,9 @@ type System interface {
type Login struct {
Home string
Shell string
// UID is the account's number, as the user database gives it: what names the account's own
// service manager to the machine's (user@<uid>.service, novox/hq ADR 0177).
UID string
}
// LookUpUser reads a login from the user database.
@@ -115,7 +118,7 @@ func LookUpUser(ctx context.Context, run Runner, name string) (Login, bool, erro
return Login{}, false, fmt.Errorf("the user database gave %q for %q, which is not a passwd entry",
strings.TrimSpace(out), name)
}
return Login{Home: fields[5], Shell: fields[6]}, true, nil
return Login{Home: fields[5], Shell: fields[6], UID: fields[2]}, true, nil
}
// GroupsOf is every group a login is in.
+75
View File
@@ -0,0 +1,75 @@
package system
import (
"context"
"errors"
"os"
"path/filepath"
"strings"
"testing"
)
// novox/hq ADR 0177: whether an account's own manager runs is asked of the machine's manager, by
// the account's number — never of the account's, which the question would start.
func TestAnAccountsManagerIsAskedOfTheMachinesManager(t *testing.T) {
var asked []string
up := false
run := func(_ context.Context, name string, args ...string) (string, error) {
line := name + " " + strings.Join(args, " ")
asked = append(asked, line)
switch {
case line == "getent passwd ops":
return "ops:x:1001:1001::/home/ops:/bin/bash\n", nil
case name == "getent":
return "", errors.New("getent exited 2: ")
case line == "systemctl is-active user@1001.service":
if up {
return "active\n", nil
}
return "inactive\n", errors.New("systemctl exited 3: ")
}
t.Fatalf("asked %q", line)
return "", nil
}
a := arch{}
for _, want := range []bool{false, true} {
up = want
running, exists, err := a.UserManagerRunning(context.Background(), run, "ops")
if err != nil || !exists || running != want {
t.Fatalf("up %v: running %v exists %v err %v", want, running, exists, err)
}
}
if _, exists, err := a.UserManagerRunning(context.Background(), run, "nobody-here"); err != nil || exists {
t.Fatalf("an account the machine does not have: exists %v err %v", exists, err)
}
for _, c := range asked {
if strings.Contains(c, "--user") || strings.Contains(c, "--machine") {
t.Fatalf("the account's own manager was asked: %s", c)
}
}
}
func TestLingeringIsReadFromLogindsRecordAndSetThroughLoginctl(t *testing.T) {
dir := t.TempDir()
defer LingerIn(dir)()
a := arch{}
if on, err := a.Lingering(context.Background(), nil, "ops"); err != nil || on {
t.Fatalf("no record read as lingering: %v %v", on, err)
}
if err := os.WriteFile(filepath.Join(dir, "ops"), nil, 0o644); err != nil {
t.Fatal(err)
}
if on, err := a.Lingering(context.Background(), nil, "ops"); err != nil || !on {
t.Fatalf("logind's record not read as lingering: %v %v", on, err)
}
var asked []string
run := func(_ context.Context, name string, args ...string) (string, error) {
asked = append(asked, name+" "+strings.Join(args, " "))
return "", nil
}
_ = a.SetLingering(context.Background(), run, "ops", true)
_ = a.SetLingering(context.Background(), run, "ops", false)
if strings.Join(asked, "; ") != "loginctl enable-linger ops; loginctl disable-linger ops" {
t.Fatalf("%v", asked)
}
}