detached adf81e6

fix(launcher): restart portals after the session export and stop them at exit

commit
adf81e65ada5c6d12e80ea813994ec05f048aade
author
brice · · 2026-10-03 06:22 UTC
parent
245942b
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
3 files · +54 -10
M internal/adapters/launcher/session.go +28 -8
@@ -13,43 +13,63 @@ import (
1313 // notification daemons) need to reach the session.
1414 var sessionVars = []string{"WAYLAND_DISPLAY", "DISPLAY", "XDG_CURRENT_DESKTOP", "XDG_SESSION_TYPE"}
1515
16+// portalUnits are the desktop portal and its backends. They read the
17+// session variables once, at start: a portal started without them (before
18+// the export, or after the last session ended) never serves this session.
19+const portalUnits = "xdg-desktop-portal*.service"
20+
1621 // ExportSession shares the session's displays with D-Bus and systemd user
17-// services, and waits for it: clients started next may activate them.
22+// services, and waits for it: clients started next may activate them. Portals
23+// already running restart to read them; the restart does not block, since a
24+// backend may connect to the display before it serves.
1825 func ExportSession(ctx context.Context, env []string, log zerowrap.Logger) {
1926 ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
2027 defer cancel()
21- runSessionCommand(ctx, env, log, append([]string{"--systemd"}, sessionVars...), "session exported")
28+ if !runSessionCommand(ctx, env, log, append([]string{"--systemd"}, sessionVars...), "session exported") {
29+ return
30+ }
31+ runSystemctl(ctx, env, log, []string{"--no-block", "try-restart", portalUnits}, "portal restart failed")
2232 }
2333
2434 // UnexportSession withdraws the displays from the systemd user manager on
25-// exit, so services started after logout do not reach a dead display.
35+// exit, so services started after logout do not reach a dead display, and
36+// stops the portals that still hold them.
2637 func UnexportSession(env []string, log zerowrap.Logger) {
2738 ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
2839 defer cancel()
40+ runSystemctl(ctx, env, log, append([]string{"unset-environment"}, sessionVars...), "session unexport failed")
41+ runSystemctl(ctx, env, log, []string{"--no-block", "stop", portalUnits}, "portal stop failed")
42+}
43+
44+// runSystemctl runs systemctl --user with args; a missing systemctl is skipped.
45+func runSystemctl(ctx context.Context, env []string, log zerowrap.Logger, args []string, failed string) {
2946 path, err := childLookPath("systemctl", env)
3047 if err != nil {
3148 return
3249 }
33- cmd := exec.CommandContext(ctx, path, append([]string{"--user", "unset-environment"}, sessionVars...)...)
50+ cmd := exec.CommandContext(ctx, path, append([]string{"--user"}, args...)...)
3451 cmd.Env = env
3552 if err := cmd.Run(); err != nil {
36- log.Warn().Err(err).Msg("session unexport failed")
53+ log.Warn().Err(err).Strs("args", args).Msg(failed)
3754 }
3855 }
3956
40-func runSessionCommand(ctx context.Context, env []string, log zerowrap.Logger, args []string, done string) {
57+// runSessionCommand runs dbus-update-activation-environment and reports
58+// whether it succeeded.
59+func runSessionCommand(ctx context.Context, env []string, log zerowrap.Logger, args []string, done string) bool {
4160 path, err := childLookPath("dbus-update-activation-environment", env)
4261 if err != nil {
4362 log.Warn().Err(err).Msg("session export skipped: dbus-update-activation-environment not found")
44- return
63+ return false
4564 }
4665 cmd := exec.CommandContext(ctx, path, args...)
4766 cmd.Env = env
4867 if err := cmd.Run(); err != nil {
4968 log.Warn().Err(err).Msg("session export failed")
50- return
69+ return false
5170 }
5271 log.Info().Strs("vars", sessionVars).Msg(done)
72+ return true
5373 }
5474
5575 // NotifyReady tells the systemd user manager that the compositor is ready.
@@ -13,43 +13,63 @@ import (
13 // notification daemons) need to reach the session.13 // notification daemons) need to reach the session.
14 var sessionVars = []string{"WAYLAND_DISPLAY", "DISPLAY", "XDG_CURRENT_DESKTOP", "XDG_SESSION_TYPE"}14 var sessionVars = []string{"WAYLAND_DISPLAY", "DISPLAY", "XDG_CURRENT_DESKTOP", "XDG_SESSION_TYPE"}
15 15
16+// portalUnits are the desktop portal and its backends. They read the
17+// session variables once, at start: a portal started without them (before
18+// the export, or after the last session ended) never serves this session.
19+const portalUnits = "xdg-desktop-portal*.service"
20+
16 // ExportSession shares the session's displays with D-Bus and systemd user21 // ExportSession shares the session's displays with D-Bus and systemd user
17-// services, and waits for it: clients started next may activate them.22+// services, and waits for it: clients started next may activate them. Portals
23+// already running restart to read them; the restart does not block, since a
24+// backend may connect to the display before it serves.
18 func ExportSession(ctx context.Context, env []string, log zerowrap.Logger) {25 func ExportSession(ctx context.Context, env []string, log zerowrap.Logger) {
19 ctx, cancel := context.WithTimeout(ctx, 5*time.Second)26 ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
20 defer cancel()27 defer cancel()
21- runSessionCommand(ctx, env, log, append([]string{"--systemd"}, sessionVars...), "session exported")28+ if !runSessionCommand(ctx, env, log, append([]string{"--systemd"}, sessionVars...), "session exported") {
29+ return
30+ }
31+ runSystemctl(ctx, env, log, []string{"--no-block", "try-restart", portalUnits}, "portal restart failed")
22 }32 }
23 33
24 // UnexportSession withdraws the displays from the systemd user manager on34 // UnexportSession withdraws the displays from the systemd user manager on
25-// exit, so services started after logout do not reach a dead display.35+// exit, so services started after logout do not reach a dead display, and
36+// stops the portals that still hold them.
26 func UnexportSession(env []string, log zerowrap.Logger) {37 func UnexportSession(env []string, log zerowrap.Logger) {
27 ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)38 ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
28 defer cancel()39 defer cancel()
40+ runSystemctl(ctx, env, log, append([]string{"unset-environment"}, sessionVars...), "session unexport failed")
41+ runSystemctl(ctx, env, log, []string{"--no-block", "stop", portalUnits}, "portal stop failed")
42+}
43+
44+// runSystemctl runs systemctl --user with args; a missing systemctl is skipped.
45+func runSystemctl(ctx context.Context, env []string, log zerowrap.Logger, args []string, failed string) {
29 path, err := childLookPath("systemctl", env)46 path, err := childLookPath("systemctl", env)
30 if err != nil {47 if err != nil {
31 return48 return
32 }49 }
33- cmd := exec.CommandContext(ctx, path, append([]string{"--user", "unset-environment"}, sessionVars...)...)50+ cmd := exec.CommandContext(ctx, path, append([]string{"--user"}, args...)...)
34 cmd.Env = env51 cmd.Env = env
35 if err := cmd.Run(); err != nil {52 if err := cmd.Run(); err != nil {
36- log.Warn().Err(err).Msg("session unexport failed")53+ log.Warn().Err(err).Strs("args", args).Msg(failed)
37 }54 }
38 }55 }
39 56
40-func runSessionCommand(ctx context.Context, env []string, log zerowrap.Logger, args []string, done string) {57+// runSessionCommand runs dbus-update-activation-environment and reports
58+// whether it succeeded.
59+func runSessionCommand(ctx context.Context, env []string, log zerowrap.Logger, args []string, done string) bool {
41 path, err := childLookPath("dbus-update-activation-environment", env)60 path, err := childLookPath("dbus-update-activation-environment", env)
42 if err != nil {61 if err != nil {
43 log.Warn().Err(err).Msg("session export skipped: dbus-update-activation-environment not found")62 log.Warn().Err(err).Msg("session export skipped: dbus-update-activation-environment not found")
44- return63+ return false
45 }64 }
46 cmd := exec.CommandContext(ctx, path, args...)65 cmd := exec.CommandContext(ctx, path, args...)
47 cmd.Env = env66 cmd.Env = env
48 if err := cmd.Run(); err != nil {67 if err := cmd.Run(); err != nil {
49 log.Warn().Err(err).Msg("session export failed")68 log.Warn().Err(err).Msg("session export failed")
50- return69+ return false
51 }70 }
52 log.Info().Strs("vars", sessionVars).Msg(done)71 log.Info().Strs("vars", sessionVars).Msg(done)
72+ return true
53 }73 }
54 74
55 // NotifyReady tells the systemd user manager that the compositor is ready.75 // NotifyReady tells the systemd user manager that the compositor is ready.
M internal/adapters/launcher/session_test.go +24 -2
@@ -32,17 +32,39 @@ func TestExportSession(t *testing.T) {
3232 t.Fatal(err)
3333 }
3434 want := "dbus-update-activation-environment --systemd WAYLAND_DISPLAY DISPLAY XDG_CURRENT_DESKTOP XDG_SESSION_TYPE wayland-9\n" +
35- "systemctl --user unset-environment WAYLAND_DISPLAY DISPLAY XDG_CURRENT_DESKTOP XDG_SESSION_TYPE wayland-9\n"
35+ "systemctl --user --no-block try-restart xdg-desktop-portal*.service wayland-9\n" +
36+ "systemctl --user unset-environment WAYLAND_DISPLAY DISPLAY XDG_CURRENT_DESKTOP XDG_SESSION_TYPE wayland-9\n" +
37+ "systemctl --user --no-block stop xdg-desktop-portal*.service wayland-9\n"
3638 if string(data) != want {
3739 t.Fatalf("calls:\n%s", data)
3840 }
3941 // Missing tools are skipped, not fatal.
4042 ExportSession(ctx, []string{"PATH=" + t.TempDir()}, logging.For(ctx, "launcher"))
41- if strings.Count(string(data), "\n") != 2 {
43+ if strings.Count(string(data), "\n") != 4 {
4244 t.Fatal(string(data))
4345 }
4446 }
4547
48+// A failed export restarts no portal: it would read the old variables again.
49+func TestExportSessionFailedSkipsPortals(t *testing.T) {
50+ dir := t.TempDir()
51+ out := filepath.Join(dir, "calls")
52+ scripts := map[string]string{
53+ "dbus-update-activation-environment": "#!/bin/sh\nexit 1\n",
54+ "systemctl": "#!/bin/sh\necho \"$*\" >> " + out + "\n",
55+ }
56+ for name, script := range scripts {
57+ if err := os.WriteFile(filepath.Join(dir, name), []byte(script), 0o755); err != nil {
58+ t.Fatal(err)
59+ }
60+ }
61+ ctx := context.Background()
62+ ExportSession(ctx, []string{"PATH=" + dir}, logging.For(ctx, "launcher"))
63+ if data, err := os.ReadFile(out); err == nil {
64+ t.Fatalf("systemctl called: %s", data)
65+ }
66+}
67+
4668 func TestNotifyReady(t *testing.T) {
4769 ctx := context.Background()
4870 log := logging.For(ctx, "launcher")
@@ -32,17 +32,39 @@ func TestExportSession(t *testing.T) {
32 t.Fatal(err)32 t.Fatal(err)
33 }33 }
34 want := "dbus-update-activation-environment --systemd WAYLAND_DISPLAY DISPLAY XDG_CURRENT_DESKTOP XDG_SESSION_TYPE wayland-9\n" +34 want := "dbus-update-activation-environment --systemd WAYLAND_DISPLAY DISPLAY XDG_CURRENT_DESKTOP XDG_SESSION_TYPE wayland-9\n" +
35- "systemctl --user unset-environment WAYLAND_DISPLAY DISPLAY XDG_CURRENT_DESKTOP XDG_SESSION_TYPE wayland-9\n"35+ "systemctl --user --no-block try-restart xdg-desktop-portal*.service wayland-9\n" +
36+ "systemctl --user unset-environment WAYLAND_DISPLAY DISPLAY XDG_CURRENT_DESKTOP XDG_SESSION_TYPE wayland-9\n" +
37+ "systemctl --user --no-block stop xdg-desktop-portal*.service wayland-9\n"
36 if string(data) != want {38 if string(data) != want {
37 t.Fatalf("calls:\n%s", data)39 t.Fatalf("calls:\n%s", data)
38 }40 }
39 // Missing tools are skipped, not fatal.41 // Missing tools are skipped, not fatal.
40 ExportSession(ctx, []string{"PATH=" + t.TempDir()}, logging.For(ctx, "launcher"))42 ExportSession(ctx, []string{"PATH=" + t.TempDir()}, logging.For(ctx, "launcher"))
41- if strings.Count(string(data), "\n") != 2 {43+ if strings.Count(string(data), "\n") != 4 {
42 t.Fatal(string(data))44 t.Fatal(string(data))
43 }45 }
44 }46 }
45 47
48+// A failed export restarts no portal: it would read the old variables again.
49+func TestExportSessionFailedSkipsPortals(t *testing.T) {
50+ dir := t.TempDir()
51+ out := filepath.Join(dir, "calls")
52+ scripts := map[string]string{
53+ "dbus-update-activation-environment": "#!/bin/sh\nexit 1\n",
54+ "systemctl": "#!/bin/sh\necho \"$*\" >> " + out + "\n",
55+ }
56+ for name, script := range scripts {
57+ if err := os.WriteFile(filepath.Join(dir, name), []byte(script), 0o755); err != nil {
58+ t.Fatal(err)
59+ }
60+ }
61+ ctx := context.Background()
62+ ExportSession(ctx, []string{"PATH=" + dir}, logging.For(ctx, "launcher"))
63+ if data, err := os.ReadFile(out); err == nil {
64+ t.Fatalf("systemctl called: %s", data)
65+ }
66+}
67+
46 func TestNotifyReady(t *testing.T) {68 func TestNotifyReady(t *testing.T) {
47 ctx := context.Background()69 ctx := context.Background()
48 log := logging.For(ctx, "launcher")70 log := logging.For(ctx, "launcher")
M packaging/neferwl-session +2 -0
@@ -17,3 +17,5 @@ fi
1717 systemctl --user --wait start neferwl.service
1818 systemctl --user start --job-mode=replace-irreversibly neferwl-shutdown.target
1919 systemctl --user unset-environment WAYLAND_DISPLAY DISPLAY XDG_SESSION_TYPE XDG_CURRENT_DESKTOP NEFERWL_STATE
20+# Portals keep the session variables they started with: stop them, even after a crash.
21+systemctl --user --no-block stop 'xdg-desktop-portal*.service'
@@ -17,3 +17,5 @@ fi
17 systemctl --user --wait start neferwl.service17 systemctl --user --wait start neferwl.service
18 systemctl --user start --job-mode=replace-irreversibly neferwl-shutdown.target18 systemctl --user start --job-mode=replace-irreversibly neferwl-shutdown.target
19 systemctl --user unset-environment WAYLAND_DISPLAY DISPLAY XDG_SESSION_TYPE XDG_CURRENT_DESKTOP NEFERWL_STATE19 systemctl --user unset-environment WAYLAND_DISPLAY DISPLAY XDG_SESSION_TYPE XDG_CURRENT_DESKTOP NEFERWL_STATE
20+# Portals keep the session variables they started with: stop them, even after a crash.
21+systemctl --user --no-block stop 'xdg-desktop-portal*.service'

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 25

Tags 10

↑↓ moveenter openesc close