Skip to content

[host][linux]: Fall back to loginctl when utmp is unavailable - #2132

Open
SebastianLeitz wants to merge 1 commit into
shirou:masterfrom
SebastianLeitz:feature/users-loginctl-fallback
Open

[host][linux]: Fall back to loginctl when utmp is unavailable#2132
SebastianLeitz wants to merge 1 commit into
shirou:masterfrom
SebastianLeitz:feature/users-loginctl-fallback

Conversation

@SebastianLeitz

Copy link
Copy Markdown

This is my attempt to solve #1938 - feel free to ignore if I stepped out of line.

Some newer distributions (in my case, Debian 13) no longer populate /run/utmp at all, tracking logged-in sessions through systemd-logind instead. When the utmp file doesn't exist, UsersWithContext now falls back to loginctl list-sessions -o json followed by a loginctl show-session per session (run concurrently) to build the same []UserStat result, instead of returning an error. The behavior is unchanged on systems where utmp exists, which also includes an empty one.

Some newer distributions no longer populate /run/utmp at all, tracking
logged-in sessions through systemd-logind instead. When the utmp file
doesn't exist, UsersWithContext now falls back to `loginctl
list-sessions -o json` followed by a `loginctl show-session` per
session (run concurrently) to build the same []UserStat result,
instead of returning an error. Behavior is unchanged on systems where
utmp exists, including an empty one.

@shirou shirou left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Edit: This comment was posted on the wrong PR by mistake. Please disregard it.

Thank you for taking this on — this is exactly what I hoped for in #1898, and I'd like to merge it. Running the suite on a real FreeBSD kernel with no source changes is a great addition.

@SebastianLeitz

Copy link
Copy Markdown
Author

@shirou Are you sure that your comment/review is in the correct PR? The topics you discuss and the issues you reference do not have anything in common with the one I was aiming to fix. Maybe wrong browser tab?

@shirou

shirou commented Aug 10, 2026

Copy link
Copy Markdown
Owner

@SebastianLeitz Oh... I'm terribly sorry — I mistakenly replied to the wrong PR. My apologies for the confusion.

@shirou shirou left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you for taking this on — and no, you didn't step out of line, this is exactly what I asked for in #1938. I'd like to merge it.

First, an apology. When I suggested loginctl list-sessions -o json in #1938, it worked — the sample JSON in that thread came from Ubuntu 24.04, which is systemd 255. But systemd 256 turned -o into a journal-only option, so list-sessions now ignores it and prints the table instead. Debian 13 (systemd 257) and Ubuntu 26.04 (systemd 259) are both past that change, which means the flag I gave you no longer produces JSON on exactly the systems this PR targets. Sorry for the bad starting point.

On my Ubuntu 26.04 box with no /run/utmp, your branch still fails — only the message changes:

--- FAIL: TestUsers
    Received unexpected error: invalid character 'S' looking for beginning of value

The 'S' is the first letter of the SESSION header.

Therefore four things before merging:

  1. Read the column output instead of JSON.

--json=short would also work, but only from v256 (June 2024), and gopsutil should cover as many systems as it can. The column form reaches much further back — I walked the tags down to v208 (2013), and the first column has been the session id the whole way; --no-legend has been there since v210 (February 2014).

out, err := invoke.CommandWithContext(ctx, "loginctl", "list-sessions", "--no-legend", "--no-pager")
...
for _, line := range strings.Split(string(out), "\n") {
	fields := strings.Fields(line)
	if len(fields) == 0 {
		continue
	}
	id := fields[0]

Please use strings.Fields rather than SplitN — the session column is right-aligned in every version (printf("%10s …") up to v235, table_set_align_percent(…, 100) from v239), so short ids carry leading spaces; @nook24's paste in #1938 shows it. This also drops encoding/json and loginctlSession.

  1. Filter the sessions.

utmp only ever returned USER_PROCESS; list-sessions returns everything logind tracks. One SSH login gives me two sessions — class=user on pts/1 and class=manager with no tty — so Users() reports the same user twice, and on a desktop gdm's greeter becomes a logged-in user.

who on Debian 13 goes through gnulib's readutmp.c, which keeps a session only when class starts with user and at least one of seat/tty is set. Adding -p Class -p Seat to the show-session call covers it — note that -p is a show-* option, so passing it to list-sessions does nothing:

$ loginctl show-session c1 -p Class -p Seat -p TTY
Seat=
TTY=pts/1
Class=user

$ loginctl show-session c2 -p Class -p Seat -p TTY
Seat=
TTY=
Class=manager

Use a prefix match rather than == "user" (user-early exists), and please don't drop on empty TTY alone — graphical logins have only a seat. Reading Class here rather than from list-sessions also matters because that column only exists from v257, whereas the properties go back to v219.

  1. Timestamp silently yields 0 in some timezones.

%Z is numeric in a fair number of regions, and Go's MST layout rejects the minute-offset ones (+0330 Iran, +0430, +0545, +0630), so the parse fails and the discarded error leaves Started at 0. Named abbreviations and +05/-03 are fine. Dropping the abbreviation and reading the wall clock in time.Local is correct everywhere:

if f := strings.Fields(value); len(f) >= 3 {
	if t, err := time.ParseInLocation("2006-01-02 15:04:05", f[1]+" "+f[2], time.Local); err == nil {
		stat.Started = int(t.Unix())
	}
}
  1. Make the show-session calls a plain loop.

sync, sessionResult and the goroutines can all go — session counts are small and Users() isn't a hot path. Passing all ids to one show-session is tempting, but a single stale id fails the whole call with exit 1, and sessions do close between the two commands.

On the tests — they pass while the real thing is broken, which is worth addressing on its own. The fake ignores everything past arg[0], so it can't tell -o json from the fix; asserting the full argument slice would have caught it. The Started expectation is computed with the same time.Parse the implementation uses, so it passes either way — a literal epoch would be better. And the os.IsNotExist branch is never exercised; common.EnvMap{common.HostVarEnvKey: t.TempDir()} gets you in there (existing example at host/host_linux_test.go:71).

Two smaller ones, take or leave: if every show-session fails we return zero users and a nil error, which is indistinguishable from "nobody is logged in"; and Users() passes context.Background() while CommandWithContext doesn't apply common.Timeout, so a wedged logind would hang it forever.

Thanks again, and sorry once more for the wrong starting point.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants