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 Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
3 files · +54 -10
M internal/adapters/launcher/session.go +28 -8
| @@ -13,43 +13,63 @@ import ( | |||
| 13 | 13 | // notification daemons) need to reach the session. | |
| 14 | 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 | 21 | // 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 | 25 | func ExportSession(ctx context.Context, env []string, log zerowrap.Logger) { | |
| 19 | 26 | ctx, cancel := context.WithTimeout(ctx, 5*time.Second) | |
| 20 | 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 | 34 | // 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 | 37 | func UnexportSession(env []string, log zerowrap.Logger) { | |
| 27 | 38 | ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) | |
| 28 | 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 | 46 | path, err := childLookPath("systemctl", env) | |
| 30 | 47 | if err != nil { | |
| 31 | 48 | 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 | 51 | cmd.Env = env | |
| 35 | 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 | 60 | path, err := childLookPath("dbus-update-activation-environment", env) | |
| 42 | 61 | if err != nil { | |
| 43 | 62 | log.Warn().Err(err).Msg("session export skipped: dbus-update-activation-environment not found") | |
| 44 | - | return | |
| 63 | + | return false | |
| 45 | 64 | } | |
| 46 | 65 | cmd := exec.CommandContext(ctx, path, args...) | |
| 47 | 66 | cmd.Env = env | |
| 48 | 67 | if err := cmd.Run(); err != nil { | |
| 49 | 68 | log.Warn().Err(err).Msg("session export failed") | |
| 50 | - | return | |
| 69 | + | return false | |
| 51 | 70 | } | |
| 52 | 71 | log.Info().Strs("vars", sessionVars).Msg(done) | |
| 72 | + | return true | |
| 53 | 73 | } | |
| 54 | 74 | ||
| 55 | 75 | // 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 user | 21 | // 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 on | 34 | // 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 | return | 48 | 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 = env | 51 | 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 | - | return | 63 | + | return false |
| 45 | } | 64 | } | ||
| 46 | cmd := exec.CommandContext(ctx, path, args...) | 65 | cmd := exec.CommandContext(ctx, path, args...) | ||
| 47 | cmd.Env = env | 66 | 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 | - | return | 69 | + | 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) { | |||
| 32 | 32 | t.Fatal(err) | |
| 33 | 33 | } | |
| 34 | 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 | 38 | if string(data) != want { | |
| 37 | 39 | t.Fatalf("calls:\n%s", data) | |
| 38 | 40 | } | |
| 39 | 41 | // Missing tools are skipped, not fatal. | |
| 40 | 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 | 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 | 68 | func TestNotifyReady(t *testing.T) { | |
| 47 | 69 | ctx := context.Background() | |
| 48 | 70 | 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 | |||
| 17 | 17 | systemctl --user --wait start neferwl.service | |
| 18 | 18 | systemctl --user start --job-mode=replace-irreversibly neferwl-shutdown.target | |
| 19 | 19 | 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.service | 17 | systemctl --user --wait start neferwl.service | ||
| 18 | systemctl --user start --job-mode=replace-irreversibly neferwl-shutdown.target | 18 | 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_STATE | 19 | 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' | |||