fix(logind): fall back to XDG_SESSION_ID when the PID has no session
NeferWL started through its systemd user unit runs outside the session scope, so logind finds no session for neferafk's PID and the daemon refused to start. When GetSessionByPID fails, resolve the display manager's session from XDG_SESSION_ID instead; the real-UID check still applies to either result.
- commit
- db1783e19bff747a43bcd1820034b1dc0bc36a4d
- author
- brice · · 2026-10-01 07:30 UTC
- parent
- c196c81
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
2 files · +54 -2
M internal/adapters/logind/logind.go +23 -2
| @@ -97,9 +97,9 @@ func (a *Adapter) Probe(ctx context.Context) error { | |||
| 97 | 97 | a.owner = owner | |
| 98 | 98 | a.caps = ports.SystemCapabilities{} | |
| 99 | 99 | if a.requirements.Session { | |
| 100 | - | body, err = a.call(ctx, managerPath, managerInterface+".GetSessionByPID", uint32(os.Getpid())) | |
| 100 | + | body, err = a.currentSession(ctx) | |
| 101 | 101 | if err != nil { | |
| 102 | - | return fmt.Errorf("current process session unavailable: %w", err) | |
| 102 | + | return err | |
| 103 | 103 | } | |
| 104 | 104 | if len(body) != 1 { | |
| 105 | 105 | return errors.New("invalid session reply") | |
| @@ -157,6 +157,27 @@ func (a *Adapter) Probe(ctx context.Context) error { | |||
| 157 | 157 | } | |
| 158 | 158 | return nil | |
| 159 | 159 | } | |
| 160 | + | ||
| 161 | + | // currentSession resolves the process's session by PID. A compositor started | |
| 162 | + | // as a systemd user service runs outside the session scope, so its children | |
| 163 | + | // have no PID session; the session the display manager opened is then taken | |
| 164 | + | // from XDG_SESSION_ID. Probe checks either result against the real UID. | |
| 165 | + | func (a *Adapter) currentSession(ctx context.Context) ([]any, error) { | |
| 166 | + | body, err := a.call(ctx, managerPath, managerInterface+".GetSessionByPID", uint32(os.Getpid())) | |
| 167 | + | if err == nil { | |
| 168 | + | return body, nil | |
| 169 | + | } | |
| 170 | + | id := os.Getenv("XDG_SESSION_ID") | |
| 171 | + | if id == "" { | |
| 172 | + | return nil, fmt.Errorf("current process session unavailable: %w", err) | |
| 173 | + | } | |
| 174 | + | body, idErr := a.call(ctx, managerPath, managerInterface+".GetSession", id) | |
| 175 | + | if idErr != nil { | |
| 176 | + | return nil, fmt.Errorf("current process session unavailable: %w; XDG_SESSION_ID: %w", err, idErr) | |
| 177 | + | } | |
| 178 | + | return body, nil | |
| 179 | + | } | |
| 180 | + | ||
| 160 | 181 | func (a *Adapter) Capabilities() ports.SystemCapabilities { | |
| 161 | 182 | c := a.caps | |
| 162 | 183 | c.Missing = append([]string(nil), c.Missing...) | |
| @@ -97,9 +97,9 @@ func (a *Adapter) Probe(ctx context.Context) error { | |||||
| 97 | a.owner = owner | 97 | a.owner = owner | ||
| 98 | a.caps = ports.SystemCapabilities{} | 98 | a.caps = ports.SystemCapabilities{} | ||
| 99 | if a.requirements.Session { | 99 | if a.requirements.Session { | ||
| 100 | - | body, err = a.call(ctx, managerPath, managerInterface+".GetSessionByPID", uint32(os.Getpid())) | 100 | + | body, err = a.currentSession(ctx) |
| 101 | if err != nil { | 101 | if err != nil { | ||
| 102 | - | return fmt.Errorf("current process session unavailable: %w", err) | 102 | + | return err |
| 103 | } | 103 | } | ||
| 104 | if len(body) != 1 { | 104 | if len(body) != 1 { | ||
| 105 | return errors.New("invalid session reply") | 105 | return errors.New("invalid session reply") | ||
| @@ -157,6 +157,27 @@ func (a *Adapter) Probe(ctx context.Context) error { | |||||
| 157 | } | 157 | } | ||
| 158 | return nil | 158 | return nil | ||
| 159 | } | 159 | } | ||
| 160 | + | ||||
| 161 | + | // currentSession resolves the process's session by PID. A compositor started | |||
| 162 | + | // as a systemd user service runs outside the session scope, so its children | |||
| 163 | + | // have no PID session; the session the display manager opened is then taken | |||
| 164 | + | // from XDG_SESSION_ID. Probe checks either result against the real UID. | |||
| 165 | + | func (a *Adapter) currentSession(ctx context.Context) ([]any, error) { | |||
| 166 | + | body, err := a.call(ctx, managerPath, managerInterface+".GetSessionByPID", uint32(os.Getpid())) | |||
| 167 | + | if err == nil { | |||
| 168 | + | return body, nil | |||
| 169 | + | } | |||
| 170 | + | id := os.Getenv("XDG_SESSION_ID") | |||
| 171 | + | if id == "" { | |||
| 172 | + | return nil, fmt.Errorf("current process session unavailable: %w", err) | |||
| 173 | + | } | |||
| 174 | + | body, idErr := a.call(ctx, managerPath, managerInterface+".GetSession", id) | |||
| 175 | + | if idErr != nil { | |||
| 176 | + | return nil, fmt.Errorf("current process session unavailable: %w; XDG_SESSION_ID: %w", err, idErr) | |||
| 177 | + | } | |||
| 178 | + | return body, nil | |||
| 179 | + | } | |||
| 180 | + | ||||
| 160 | func (a *Adapter) Capabilities() ports.SystemCapabilities { | 181 | func (a *Adapter) Capabilities() ports.SystemCapabilities { | ||
| 161 | c := a.caps | 182 | c := a.caps | ||
| 162 | c.Missing = append([]string(nil), c.Missing...) | 183 | c.Missing = append([]string(nil), c.Missing...) | ||
M internal/adapters/logind/logind_test.go +31 -0
| @@ -39,6 +39,37 @@ func TestProbeCurrentPIDRealUIDAndReadOnly(t *testing.T) { | |||
| 39 | 39 | } | |
| 40 | 40 | // No mutating call was configured: generated mock rejects Inhibit/Suspend. | |
| 41 | 41 | } | |
| 42 | + | ||
| 43 | + | // A compositor run as a systemd user service gives its children no PID | |
| 44 | + | // session; the display manager's XDG_SESSION_ID is used, UID-checked. | |
| 45 | + | func TestProbeFallsBackToXDGSessionID(t *testing.T) { | |
| 46 | + | t.Setenv("XDG_SESSION_ID", "c1") | |
| 47 | + | for name, uid := range map[string]uint32{"same uid": uint32(os.Getuid()), "other uid": uint32(os.Getuid()) + 1} { | |
| 48 | + | t.Run(name, func(t *testing.T) { | |
| 49 | + | b := newMockbus(t) | |
| 50 | + | b.EXPECT().call(mock.Anything, "org.freedesktop.DBus", dbus.ObjectPath("/org/freedesktop/DBus"), "org.freedesktop.DBus.GetNameOwner", []any{service}).Return([]any{testOwner}, nil).Once() | |
| 51 | + | b.EXPECT().call(mock.Anything, testOwner, managerPath, managerInterface+".GetSessionByPID", []any{uint32(os.Getpid())}).Return(nil, errors.New("no session")).Once() | |
| 52 | + | b.EXPECT().call(mock.Anything, testOwner, managerPath, managerInterface+".GetSession", []any{"c1"}).Return([]any{testSession}, nil).Once() | |
| 53 | + | b.EXPECT().call(mock.Anything, testOwner, testSession, "org.freedesktop.DBus.Properties.Get", []any{sessionInterface, "User"}).Return([]any{dbus.MakeVariant([]any{uid, dbus.ObjectPath("/org/freedesktop/login1/user/_1000")})}, nil).Once() | |
| 54 | + | a := newAdapter(b, ports.SystemRequirements{Session: true}) | |
| 55 | + | err := a.Probe(context.Background()) | |
| 56 | + | if ok := uid == uint32(os.Getuid()); ok != (err == nil) || ok != a.Capabilities().Session || ok != (a.session == testSession) { | |
| 57 | + | t.Fatalf("err=%v caps=%+v session=%q", err, a.Capabilities(), a.session) | |
| 58 | + | } | |
| 59 | + | }) | |
| 60 | + | } | |
| 61 | + | } | |
| 62 | + | ||
| 63 | + | func TestProbeWithoutPIDSessionOrXDGSessionIDFails(t *testing.T) { | |
| 64 | + | t.Setenv("XDG_SESSION_ID", "") | |
| 65 | + | b := newMockbus(t) | |
| 66 | + | b.EXPECT().call(mock.Anything, "org.freedesktop.DBus", dbus.ObjectPath("/org/freedesktop/DBus"), "org.freedesktop.DBus.GetNameOwner", []any{service}).Return([]any{testOwner}, nil).Once() | |
| 67 | + | b.EXPECT().call(mock.Anything, testOwner, managerPath, managerInterface+".GetSessionByPID", []any{uint32(os.Getpid())}).Return(nil, errors.New("no session")).Once() | |
| 68 | + | if err := newAdapter(b, ports.SystemRequirements{Session: true}).Probe(context.Background()); err == nil { | |
| 69 | + | t.Fatal("probe succeeded without any session") | |
| 70 | + | } | |
| 71 | + | } | |
| 72 | + | ||
| 42 | 73 | func TestProbeRejectsOtherUID(t *testing.T) { | |
| 43 | 74 | b := newMockbus(t) | |
| 44 | 75 | expectProbe(t, b, uint32(os.Getuid())+1) | |
| @@ -39,6 +39,37 @@ func TestProbeCurrentPIDRealUIDAndReadOnly(t *testing.T) { | |||||
| 39 | } | 39 | } | ||
| 40 | // No mutating call was configured: generated mock rejects Inhibit/Suspend. | 40 | // No mutating call was configured: generated mock rejects Inhibit/Suspend. | ||
| 41 | } | 41 | } | ||
| 42 | + | ||||
| 43 | + | // A compositor run as a systemd user service gives its children no PID | |||
| 44 | + | // session; the display manager's XDG_SESSION_ID is used, UID-checked. | |||
| 45 | + | func TestProbeFallsBackToXDGSessionID(t *testing.T) { | |||
| 46 | + | t.Setenv("XDG_SESSION_ID", "c1") | |||
| 47 | + | for name, uid := range map[string]uint32{"same uid": uint32(os.Getuid()), "other uid": uint32(os.Getuid()) + 1} { | |||
| 48 | + | t.Run(name, func(t *testing.T) { | |||
| 49 | + | b := newMockbus(t) | |||
| 50 | + | b.EXPECT().call(mock.Anything, "org.freedesktop.DBus", dbus.ObjectPath("/org/freedesktop/DBus"), "org.freedesktop.DBus.GetNameOwner", []any{service}).Return([]any{testOwner}, nil).Once() | |||
| 51 | + | b.EXPECT().call(mock.Anything, testOwner, managerPath, managerInterface+".GetSessionByPID", []any{uint32(os.Getpid())}).Return(nil, errors.New("no session")).Once() | |||
| 52 | + | b.EXPECT().call(mock.Anything, testOwner, managerPath, managerInterface+".GetSession", []any{"c1"}).Return([]any{testSession}, nil).Once() | |||
| 53 | + | b.EXPECT().call(mock.Anything, testOwner, testSession, "org.freedesktop.DBus.Properties.Get", []any{sessionInterface, "User"}).Return([]any{dbus.MakeVariant([]any{uid, dbus.ObjectPath("/org/freedesktop/login1/user/_1000")})}, nil).Once() | |||
| 54 | + | a := newAdapter(b, ports.SystemRequirements{Session: true}) | |||
| 55 | + | err := a.Probe(context.Background()) | |||
| 56 | + | if ok := uid == uint32(os.Getuid()); ok != (err == nil) || ok != a.Capabilities().Session || ok != (a.session == testSession) { | |||
| 57 | + | t.Fatalf("err=%v caps=%+v session=%q", err, a.Capabilities(), a.session) | |||
| 58 | + | } | |||
| 59 | + | }) | |||
| 60 | + | } | |||
| 61 | + | } | |||
| 62 | + | ||||
| 63 | + | func TestProbeWithoutPIDSessionOrXDGSessionIDFails(t *testing.T) { | |||
| 64 | + | t.Setenv("XDG_SESSION_ID", "") | |||
| 65 | + | b := newMockbus(t) | |||
| 66 | + | b.EXPECT().call(mock.Anything, "org.freedesktop.DBus", dbus.ObjectPath("/org/freedesktop/DBus"), "org.freedesktop.DBus.GetNameOwner", []any{service}).Return([]any{testOwner}, nil).Once() | |||
| 67 | + | b.EXPECT().call(mock.Anything, testOwner, managerPath, managerInterface+".GetSessionByPID", []any{uint32(os.Getpid())}).Return(nil, errors.New("no session")).Once() | |||
| 68 | + | if err := newAdapter(b, ports.SystemRequirements{Session: true}).Probe(context.Background()); err == nil { | |||
| 69 | + | t.Fatal("probe succeeded without any session") | |||
| 70 | + | } | |||
| 71 | + | } | |||
| 72 | + | ||||
| 42 | func TestProbeRejectsOtherUID(t *testing.T) { | 73 | func TestProbeRejectsOtherUID(t *testing.T) { | ||
| 43 | b := newMockbus(t) | 74 | b := newMockbus(t) | ||
| 44 | expectProbe(t, b, uint32(os.Getuid())+1) | 75 | expectProbe(t, b, uint32(os.Getuid())+1) | ||