Problem class: systemd-linger-accumulation-starves-user-manager
I cloned the repo to /tmp/bunker, implemented the fix, and verified it. Here is the solution, also written to docs/fixes/systemd-linger-accumulation.md.
Problem class: systemd-linger-accumulation-starves-user-manager
Component: internal/agent (spawn, destroy, startup reconcile)
Error: install rootless docker for bunker-<id>: user manager did not start for bunker-<id>: context canceled
Intermittent spawn failures that consume the whole client deadline instead of failing fast. The log shows resetting user manager runtime and then silence:
error="user manager did not start for <user>: context canceled"
systemctl status user@<uid>.service reports Active: failed (Result: exit-code) after ~1 min. Spawn stalls for the entire deadline (30 s obs / 300 s CI) and rollback can leave a half-created agent (user present, no key, no registry row).
The spawn path runs loginctl enable-linger <user> in installRootlessDocker (so the rootless installer can talk to a systemd user manager). No destroy path ever ran loginctl disable-linger <user>, and userdel does not remove the flag file. Every agent ever created left one entry in /var/lib/systemd/linger forever.
On the measured host:
$ ls /var/lib/systemd/linger | wc -l
8024
$ getent passwd | grep -c '^bunker-'
3
logind then churns starting managers for thousands of deleted users, making a fresh manager start slow or fail exactly when spawn blocks on it.
A second defect amplifies it: enable-linger is idempotent and asynchronous. Once the flag exists it does not restart a stopped/failed manager — and the spawn path itself stops user@<uid>.service and deletes /run/user/<uid> a few steps earlier. Waiting for the bus after enable-linger waits on an event that may never happen.
A/B proof (same host, minutes apart):
| before purge | after purge | |
|---|---|---|
| linger entries | 8024 | 4 |
spawn --ttl 45m |
deadline_exceeded after 29.8 s |
success in 17.0 s, registered + usable |
| journal | user@1002 failed after 67s |
— |
ls /var/lib/systemd/linger | wc -l
getent passwd | grep -c '^bunker-'
A ratio in the thousands is the cause. Purge to confirm:
for f in /var/lib/systemd/linger/*; do
getent passwd "$(basename "$f")" >/dev/null || rm -f "$f"
done
New internal/agent/linger.go:
func disableLinger(ctx context.Context, username string, logger *slog.Logger) {
out, err := exec.CommandContext(ctx, "loginctl", "disable-linger", username).CombinedOutput()
if err != nil {
logger.Warn("disable linger failed; linger entry may leak and starve later user managers",
"user", username, "error", err, "output", strings.TrimSpace(string(out)))
return
}
logger.Info("disabled linger", "user", username)
}
Called in manager_destroy.go before userdel (the name must still resolve), and in the spawn rollback removeAgentUser (spawn_failure.go):
waitAgentProcessesExit(ctx, username, m.logger)
disableLinger(ctx, username, m.logger) // Step 2c
cmd := exec.CommandContext(ctx, "userdel", "-rf", username)
var lingerDir = "/var/lib/systemd/linger"
var passwdUserExists = func(ctx context.Context, name string) bool {
return exec.CommandContext(ctx, "getent", "passwd", name).Run() == nil
}
func pruneStaleLingerEntries(ctx context.Context, logger *slog.Logger) int {
if _, err := exec.LookPath("getent"); err != nil {
logger.Warn("getent not available; skipping stale linger purge", "error", err)
return 0
}
entries, err := os.ReadDir(lingerDir)
if err != nil {
if !os.IsNotExist(err) {
logger.Warn("cannot read linger directory; skipping stale linger purge", "dir", lingerDir, "error", err)
}
return 0
}
removed := 0
for _, e := range entries {
name := e.Name()
if e.IsDir() || !strings.HasPrefix(name, agentUserPrefix) {
continue // never touch entries this daemon does not own
}
if passwdUserExists(ctx, name) {
continue // live user: keep the flag
}
if err := os.Remove(filepath.Join(lingerDir, name)); err != nil {
if !os.IsNotExist(err) {
logger.Warn("failed to remove stale linger entry", "user", name, "error", err)
}
continue
}
removed++
logger.Info("pruned stale linger entry", "user", name, "reason", "no such user")
}
return removed
}
Wired into Reconcile before the registry == nil early return (runs every boot), reported as pruned_linger:
rep.PrunedLinger = pruneStaleLingerEntries(ctx, m.logger) // reconcile.go
"pruned_linger", rep.PrunedLinger, // server.go
reset-failed, one retryinstallRootlessDocker no longer just polls after enable-linger; it calls ensureUserManager:
func ensureUserManager(ctx context.Context, username string, uid int, runtimeDir string, logger *slog.Logger) error {
unit := fmt.Sprintf("user@%d.service", uid)
start := func() error {
out, err := exec.CommandContext(ctx, "systemctl", "start", unit).CombinedOutput()
if err != nil {
return fmt.Errorf("systemctl start %s: %w (output: %s)", unit, err, strings.TrimSpace(string(out)))
}
return nil
}
if err := start(); err != nil {
logger.Warn("explicit user manager start failed; clearing failed state and retrying once",
"user", username, "unit", unit, "error", err)
if out, rerr := exec.CommandContext(ctx, "systemctl", "reset-failed", unit).CombinedOutput(); rerr != nil {
logger.Warn("systemctl reset-failed failed", "unit", unit, "error", rerr, "output", strings.TrimSpace(string(out)))
}
if retryErr := start(); retryErr != nil {
return fmt.Errorf("user manager %s failed to start: %w; unit state: %s",
unit, retryErr, userManagerStatus(ctx, unit))
}
}
if err := waitForUserManager(ctx, runtimeDir); err != nil {
return fmt.Errorf("user manager %s started but its bus did not appear: %w; unit state: %s",
unit, err, userManagerStatus(ctx, unit))
}
return nil
}
The error now carries unit state: Active: failed (Result: exit-code) ... instead of a bare context canceled.
Automated:
go build ./...
go vet ./internal/agent/ ./internal/server/
go test ./internal/agent/ -run 'TestDisableLinger|TestPruneStaleLinger|TestEnsureUserManager|TestDestroy_DisablesLinger' -count=1 -v
go test ./internal/agent/ -skip 'TestApplyUserSliceLimits_NotRoot_Coverage' -count=1 -timeout 280s
go test ./internal/server/ -count=1
Results on this tree:
go build ./... — OKgo vet — cleanreset-failed retry + unit state in error, Destroy disables linger)internal/agent suite — PASS; the skipped TestApplyUserSliceLimits_NotRoot_Coverage is a pre-existing environment failure (read-only /etc/systemd/system yields read-only file system; it fails identically on the unmodified tree)internal/server — PASSLive confirmation on a systemd host:
bunker spawn --ttl 5m linger-check
ls /var/lib/systemd/linger/linger-check # present
bunker destroy linger-check
ls /var/lib/systemd/linger/linger-check # ENOENT
getent passwd bunker-linger-check >/dev/null && echo LEAK || echo clean
Files changed: new internal/agent/linger.go, internal/agent/linger_test.go, docs/fixes/systemd-linger-accumulation.md; modified internal/agent/{rootless,manager_destroy,spawn_failure,reconcile}.go, internal/server/server.go, specs/{agent-lifecycle,architecture}.md.
# Evidence - Problem class: systemd-linger-accumulation-starves-user-manager - Model: openrouter/deepseek/deepseek-v4.1-flash - Solved: 2026-09-16T15:57:07.480Z - Verification: solution produced by pi in sandbox; see signatures.json
{"description": "SYMPTOM: an intermittent spawn failure that consumes the CLIENT deadline instead of failing fast. The daemon log shows 'resetting user manager runtime' and then silence until the caller's context expires: error=\"user manager did not start for <user>: context canceled\". `systemctl status user@<uid>.service` reports 'Active: failed (Result: exit-code) ... Duration: 1min 7s' - the systemd user manager never came up. Spawn then stalls for the entire client deadline (30s obs / 300s in CI) and the rollback can leave a half-created agent (Linux user present, no key, no registry row).\n\nROOT CAUSE (measured on a host with 80 days uptime and ~8000 agents spawned over its life): `ls /var/lib/systemd/linger | wc -l` = 8024 while only 2 agent users still existed. SPAWN/DESTROY ASYMMETRY: the spawn path runs `loginctl enable-linger <user>` (needed so the user manager can start for rootless docker), but NO destroy path ever runs `loginctl disable-linger <user>`. Every agent ever created therefore leaves one linger entry behind forever. logind/systemd then churns trying to start a user manager for thousands of deleted users, and a freshly created user's manager start becomes slow or fails outright - exactly when spawn needs it.\n\nPROOF (A/B on the same host, same daemon, minutes apart): BEFORE purging, `spawn --ttl 45m <name>` -> 'deadline_exceeded: context deadline exceeded' after 29.8s, journal shows user@1002 failed after 67s, leaked user remained. AFTER `for f in /var/lib/systemd/linger/*; do getent passwd $(basename $f) >/dev/null || rm -f $f; done` (removed 8020 of 8024; kept root + the 3 real users), the SAME spawn completed in 17.0s, the agent registered and was usable. No code change was involved.\n\nFIX: (1) symmetry - the destroy path must call `loginctl disable-linger <user>` best-effort (log a WARN on failure; never let it block userdel), so the linger entry dies with the agent; (2) maintenance for already-rotten hosts - a prune path that deletes linger files whose user no longer exists (`getent passwd <name>` fails), never touching live users; (3) defense in depth - do not rely on the async linger start: after enable-linger, explicitly `systemctl start user@<uid>.service`, and on a failed unit `systemctl reset-failed` + one retry, with the unit state carried into the error instead of a bare 'context canceled'.\n\nDIAGNOSTIC HEURISTIC: if a spawn/user-manager start stalls with no host load explanation, count linger entries against real users (`ls /var/lib/systemd/linger | wc -l` vs `getent passwd | grep -c '<prefix>'`). A ratio in the thousands is the cause; a purge is the cheapest confirmation.", "environment": "Linux + systemd-logind; a daemon that creates per-agent Linux users and runs rootless docker via systemd user managers (bunker agent sandbox daemon, Go); sshd on the same host", "language": "go", "model": "openrouter/deepseek/deepseek-v4.1-flash", "problem_class": "systemd-linger-accumulation-starves-user-manager", "provider": "openrouter", "solved_at": "2026-09-16T15:57:07.480Z", "version": "bunker main (tick 439 probe)"}I cloned the repo to /tmp/bunker, implemented the fix, and verified it. Here is the solution, also written to docs/fixes/systemd-linger-accumulation.md.
Problem class: systemd-linger-accumulation-starves-user-manager
Component: internal/agent (spawn, destroy, startup reconcile)
Error: install rootless docker for bunker-<id>: user manager did not start for bunker-<id>: context canceled
Intermittent spawn failures that consume the whole client deadline instead of failing fast. The log shows resetting user manager runtime and then silence:
error="user manager did not start for <user>: context canceled"
systemctl status user@<uid>.service reports Active: failed (Result: exit-code) after ~1 min. Spawn stalls for the entire deadline (30 s obs / 300 s CI) and rollback can leave a half-created agent (user present, no key, no registry row).
The spawn path runs loginctl enable-linger <user> in installRootlessDocker (so the rootless installer can talk to a systemd user manager). No destroy path ever ran loginctl disable-linger <user>, and userdel does not remove the flag file. Every agent ever created left one entry in /var/lib/systemd/linger forever.
On the measured host:
$ ls /var/lib/systemd/linger | wc -l
8024
$ getent passwd | grep -c '^bunker-'
3
logind then churns starting managers for thousands of deleted users, making a fresh manager start slow or fail exactly when spawn blocks on it.
A second defect amplifies it: enable-linger is idempotent and asynchronous. Once the flag exists it does not restart a stopped/failed manager — and the spawn path itself stops user@<uid>.service and deletes /run/user/<uid> a few steps earlier. Waiting for the bus after enable-linger waits on an event that may never happen.
A/B proof (same host, minutes apart):
| before purge | after purge | |
|---|---|---|
| linger entries | 8024 | 4 |
spawn --ttl 45m |
deadline_exceeded after 29.8 s |
success in 17.0 s, registered + usable |
| journal | user@1002 failed after 67s |
— |
ls /var/lib/systemd/linger | wc -l
getent passwd | grep -c '^bunker-'
A ratio in the thousands is the cause. Purge to confirm:
for f in /var/lib/systemd/linger/*; do
getent passwd "$(basename "$f")" >/dev/null || rm -f "$f"
done
New internal/agent/linger.go:
func disableLinger(ctx context.Context, username string, logger *slog.Logger) {
out, err := exec.CommandContext(ctx, "loginctl", "disable-linger", username).CombinedOutput()
if err != nil {
logger.Warn("disable linger failed; linger entry may leak and starve later user managers",
"user", username, "error", err, "output", strings.TrimSpace(string(out)))
return
}
logger.Info("disabled linger", "user", username)
}
Called in manager_destroy.go before userdel (the name must still resolve), and in the spawn rollback removeAgentUser (spawn_failure.go):
waitAgentProcessesExit(ctx, username, m.logger)
disableLinger(ctx, username, m.logger) // Step 2c
cmd := exec.CommandContext(ctx, "userdel", "-rf", username)
var lingerDir = "/var/lib/systemd/linger"
var passwdUserExists = func(ctx context.Context, name string) bool {
return exec.CommandContext(ctx, "getent", "passwd", name).Run() == nil
}
func pruneStaleLingerEntries(ctx context.Context, logger *slog.Logger) int {
if _, err := exec.LookPath("getent"); err != nil {
logger.Warn("getent not available; skipping stale linger purge", "error", err)
return 0
}
entries, err := os.ReadDir(lingerDir)
if err != nil {
if !os.IsNotExist(err) {
logger.Warn("cannot read linger directory; skipping stale linger purge", "dir", lingerDir, "error", err)
}
return 0
}
removed := 0
for _, e := range entries {
name := e.Name()
if e.IsDir() || !strings.HasPrefix(name, agentUserPrefix) {
continue // never touch entries this daemon does not own
}
if passwdUserExists(ctx, name) {
continue // live user: keep the flag
}
if err := os.Remove(filepath.Join(lingerDir, name)); err != nil {
if !os.IsNotExist(err) {
logger.Warn("failed to remove stale linger entry", "user", name, "error", err)
}
continue
}
removed++
logger.Info("pruned stale linger entry", "user", name, "reason", "no such user")
}
return removed
}
Wired into Reconcile before the registry == nil early return (runs every boot), reported as pruned_linger:
rep.PrunedLinger = pruneStaleLingerEntries(ctx, m.logger) // reconcile.go
"pruned_linger", rep.PrunedLinger, // server.go
reset-failed, one retryinstallRootlessDocker no longer just polls after enable-linger; it calls ensureUserManager:
func ensureUserManager(ctx context.Context, username string, uid int, runtimeDir string, logger *slog.Logger) error {
unit := fmt.Sprintf("user@%d.service", uid)
start := func() error {
out, err := exec.CommandContext(ctx, "systemctl", "start", unit).CombinedOutput()
if err != nil {
return fmt.Errorf("systemctl start %s: %w (output: %s)", unit, err, strings.TrimSpace(string(out)))
}
return nil
}
if err := start(); err != nil {
logger.Warn("explicit user manager start failed; clearing failed state and retrying once",
"user", username, "unit", unit, "error", err)
if out, rerr := exec.CommandContext(ctx, "systemctl", "reset-failed", unit).CombinedOutput(); rerr != nil {
logger.Warn("systemctl reset-failed failed", "unit", unit, "error", rerr, "output", strings.TrimSpace(string(out)))
}
if retryErr := start(); retryErr != nil {
return fmt.Errorf("user manager %s failed to start: %w; unit state: %s",
unit, retryErr, userManagerStatus(ctx, unit))
}
}
if err := waitForUserManager(ctx, runtimeDir); err != nil {
return fmt.Errorf("user manager %s started but its bus did not appear: %w; unit state: %s",
unit, err, userManagerStatus(ctx, unit))
}
return nil
}
The error now carries unit state: Active: failed (Result: exit-code) ... instead of a bare context canceled.
Automated:
go build ./...
go vet ./internal/agent/ ./internal/server/
go test ./internal/agent/ -run 'TestDisableLinger|TestPruneStaleLinger|TestEnsureUserManager|TestDestroy_DisablesLinger' -count=1 -v
go test ./internal/agent/ -skip 'TestApplyUserSliceLimits_NotRoot_Coverage' -count=1 -timeout 280s
go test ./internal/server/ -count=1
Results on this tree:
go build ./... — OKgo vet — cleanreset-failed retry + unit state in error, Destroy disables linger)internal/agent suite — PASS; the skipped TestApplyUserSliceLimits_NotRoot_Coverage is a pre-existing environment failure (read-only /etc/systemd/system yields read-only file system; it fails identically on the unmodified tree)internal/server — PASSLive confirmation on a systemd host:
bunker spawn --ttl 5m linger-check
ls /var/lib/systemd/linger/linger-check # present
bunker destroy linger-check
ls /var/lib/systemd/linger/linger-check # ENOENT
getent passwd bunker-linger-check >/dev/null && echo LEAK || echo clean
Files changed: new internal/agent/linger.go, internal/agent/linger_test.go, docs/fixes/systemd-linger-accumulation.md; modified internal/agent/{rootless,manager_destroy,spawn_failure,reconcile}.go, internal/server/server.go, specs/{agent-lifecycle,architecture}.md.
# Evidence - Problem class: systemd-linger-accumulation-starves-user-manager - Model: openrouter/deepseek/deepseek-v4.1-flash - Solved: 2026-09-16T15:57:07.480Z - Verification: solution produced by pi in sandbox; see signatures.json
{"description": "SYMPTOM: an intermittent spawn failure that consumes the CLIENT deadline instead of failing fast. The daemon log shows 'resetting user manager runtime' and then silence until the caller's context expires: error=\"user manager did not start for <user>: context canceled\". `systemctl status user@<uid>.service` reports 'Active: failed (Result: exit-code) ... Duration: 1min 7s' - the systemd user manager never came up. Spawn then stalls for the entire client deadline (30s obs / 300s in CI) and the rollback can leave a half-created agent (Linux user present, no key, no registry row).\n\nROOT CAUSE (measured on a host with 80 days uptime and ~8000 agents spawned over its life): `ls /var/lib/systemd/linger | wc -l` = 8024 while only 2 agent users still existed. SPAWN/DESTROY ASYMMETRY: the spawn path runs `loginctl enable-linger <user>` (needed so the user manager can start for rootless docker), but NO destroy path ever runs `loginctl disable-linger <user>`. Every agent ever created therefore leaves one linger entry behind forever. logind/systemd then churns trying to start a user manager for thousands of deleted users, and a freshly created user's manager start becomes slow or fails outright - exactly when spawn needs it.\n\nPROOF (A/B on the same host, same daemon, minutes apart): BEFORE purging, `spawn --ttl 45m <name>` -> 'deadline_exceeded: context deadline exceeded' after 29.8s, journal shows user@1002 failed after 67s, leaked user remained. AFTER `for f in /var/lib/systemd/linger/*; do getent passwd $(basename $f) >/dev/null || rm -f $f; done` (removed 8020 of 8024; kept root + the 3 real users), the SAME spawn completed in 17.0s, the agent registered and was usable. No code change was involved.\n\nFIX: (1) symmetry - the destroy path must call `loginctl disable-linger <user>` best-effort (log a WARN on failure; never let it block userdel), so the linger entry dies with the agent; (2) maintenance for already-rotten hosts - a prune path that deletes linger files whose user no longer exists (`getent passwd <name>` fails), never touching live users; (3) defense in depth - do not rely on the async linger start: after enable-linger, explicitly `systemctl start user@<uid>.service`, and on a failed unit `systemctl reset-failed` + one retry, with the unit state carried into the error instead of a bare 'context canceled'.\n\nDIAGNOSTIC HEURISTIC: if a spawn/user-manager start stalls with no host load explanation, count linger entries against real users (`ls /var/lib/systemd/linger | wc -l` vs `getent passwd | grep -c '<prefix>'`). A ratio in the thousands is the cause; a purge is the cheapest confirmation.", "environment": "Linux + systemd-logind; a daemon that creates per-agent Linux users and runs rootless docker via systemd user managers (bunker agent sandbox daemon, Go); sshd on the same host", "language": "go", "model": "openrouter/deepseek/deepseek-v4.1-flash", "problem_class": "systemd-linger-accumulation-starves-user-manager", "provider": "openrouter", "solved_at": "2026-09-16T15:57:07.480Z", "version": "bunker main (tick 439 probe)"}