From dbe9b15a87611984dd149b1c66e86f04a8912a67 Mon Sep 17 00:00:00 2001 From: Collin Pendleton Date: Sat, 17 Oct 2026 08:42:15 -0500 Subject: [PATCH] fix: clear Chromium's stale profile lock before every kiosk start --- CHANGELOG.md | 6 +++ boxd/internal/profile/profile.go | 31 +++++++++++--- boxd/internal/profile/profile_test.go | 50 ++++++++++++++++++++++ docs/adr/0006-pairing-persistence.md | 10 +++++ packaging/root/usr/lib/syncphony-box/kiosk | 7 +-- 5 files changed, 95 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4cf183d..ef961b7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -54,6 +54,12 @@ Add to `## [Unreleased]` as you go; rename it when you tag. `wpasupplicant` (NetworkManager only recommends it), so NetworkManager couldn't scan or run an access point. It also gains `wireless-regdb` and MediaTek and Broadcom Wi-Fi firmware. +- After setup gave the box a name, the TV could show Chromium's "The + profile appears to be in use by another Chromium process on another + computer" instead of the room. The rename changed the hostname, and + Chromium wouldn't take over the lock its killed predecessor left + behind. The kiosk now clears that stale lock before every start (the + same after a power cut). ### Changed - The image is smaller: documentation, man pages and translations are diff --git a/boxd/internal/profile/profile.go b/boxd/internal/profile/profile.go index bfdd40a..30ba3a0 100644 --- a/boxd/internal/profile/profile.go +++ b/boxd/internal/profile/profile.go @@ -5,8 +5,9 @@ // state survive reboots, power cuts and crashes, and both live here or are // triggered from boxd's bridge: // -// - Prep clears Chromium's "exited uncleanly" bookkeeping before every -// start, so a power cut never leaves a restore prompt on the screen. +// - Prep clears Chromium's "exited uncleanly" bookkeeping and its stale +// profile lock before every start, so a power cut or a rename never +// leaves a restore prompt or a "profile in use" error on the screen. // - Reset wipes the whole profile, so the box shows a fresh pairing code // (when it moves to another room or server). // @@ -35,9 +36,20 @@ func Path() string { return config.DefaultProfileDir } -// Prep clears the unclean-exit state Chromium records in its profile, so a -// power cut never turns into a "restore pages?" prompt on the TV. It edits -// in place, preserving every other key: +// singletonFiles are Chromium's profile lock: SingletonLock is a symlink +// to "-", the others belong to it. Chromium removes them +// on a clean exit; a kill (cage stopping under it, a power cut) leaves +// them behind. Chromium takes over a lock whose pid is dead only when the +// hostname matches, so after a rename (setup's name=, hostnamectl) it +// refuses the profile with "in use by another Chromium process on another +// computer" — on a TV with nobody there to click through it. +var singletonFiles = []string{"SingletonLock", "SingletonSocket", "SingletonCookie"} + +// Prep readies the profile for a start. It removes the stale profile lock +// (singletonFiles) — safe because the kiosk wrapper calls Prep just before +// it execs the box's only Chromium. Then it clears the unclean-exit state +// Chromium records, so a power cut never turns into a "restore pages?" +// prompt on the TV. It edits in place, preserving every other key: // // - /Default/Preferences: browser.exit_type = "Normal" // - /Local State: browser.exited_cleanly = true @@ -45,11 +57,18 @@ func Path() string { // A fresh profile (files missing) is fine and left alone: nothing to clear. // A file that is not valid JSON is reported as an error and left untouched // — the caller logs it and starts Chromium anyway, exactly as it would -// have without this call. +// have without this call. The lock goes first, so a bad JSON file never +// keeps it. func Prep(dir string) error { if dir == "" { return errors.New("profile: empty directory") } + for _, name := range singletonFiles { + // os.Remove takes the symlink itself, dangling or not. + if err := os.Remove(filepath.Join(dir, name)); err != nil && !os.IsNotExist(err) { + return err + } + } prefs := filepath.Join(dir, "Default", "Preferences") if _, err := os.Stat(prefs); err == nil { if err := setJSONKeys(prefs, map[string]any{"exit_type": "Normal"}, "browser"); err != nil { diff --git a/boxd/internal/profile/profile_test.go b/boxd/internal/profile/profile_test.go index d0e8041..c6cdf60 100644 --- a/boxd/internal/profile/profile_test.go +++ b/boxd/internal/profile/profile_test.go @@ -125,6 +125,56 @@ func TestPrepLeavesInvalidJSONAlone(t *testing.T) { } } +// writeStaleLock leaves what a killed Chromium does: SingletonLock is a +// dangling symlink to "-", here a hostname the box no +// longer has (setup renamed it). +func writeStaleLock(t *testing.T, dir string) { + t.Helper() + if err := os.Symlink("syncphony-box-a1b2-1234", filepath.Join(dir, "SingletonLock")); err != nil { + t.Fatal(err) + } + if err := os.Symlink("/tmp/.org.chromium.Chromium.gone/SingletonSocket", filepath.Join(dir, "SingletonSocket")); err != nil { + t.Fatal(err) + } + if err := os.Symlink("1234567890", filepath.Join(dir, "SingletonCookie")); err != nil { + t.Fatal(err) + } +} + +func TestPrepRemovesStaleProfileLock(t *testing.T) { + dir := t.TempDir() + writeProfile(t, dir) + writeStaleLock(t, dir) + if err := Prep(dir); err != nil { + t.Fatal(err) + } + for _, name := range singletonFiles { + if _, err := os.Lstat(filepath.Join(dir, name)); !os.IsNotExist(err) { + t.Fatalf("%s survived Prep (err=%v)", name, err) + } + } + if _, err := os.Stat(filepath.Join(dir, "Default", "Preferences")); err != nil { + t.Fatalf("profile lost with the lock: %v", err) + } +} + +func TestPrepRemovesLockEvenWithInvalidJSON(t *testing.T) { + dir := t.TempDir() + if err := os.MkdirAll(filepath.Join(dir, "Default"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "Default", "Preferences"), []byte("not json"), 0o644); err != nil { + t.Fatal(err) + } + writeStaleLock(t, dir) + if err := Prep(dir); err == nil { + t.Fatal("expected an error for invalid JSON") + } + if _, err := os.Lstat(filepath.Join(dir, "SingletonLock")); !os.IsNotExist(err) { + t.Fatalf("invalid JSON kept the lock (err=%v)", err) + } +} + func TestPrepRejectsEmptyDir(t *testing.T) { if err := Prep(""); err == nil { t.Fatal("expected an error for an empty dir") diff --git a/docs/adr/0006-pairing-persistence.md b/docs/adr/0006-pairing-persistence.md index e721400..ea29bfd 100644 --- a/docs/adr/0006-pairing-persistence.md +++ b/docs/adr/0006-pairing-persistence.md @@ -69,6 +69,16 @@ key: - `Default/Preferences`: `browser.exit_type = "Normal"` - `Local State`: `browser.exited_cleanly = true` +Before that it removes Chromium's profile lock (`SingletonLock`, +`SingletonSocket`, `SingletonCookie`). A killed Chromium — cage stopped +under it on a kiosk restart, or a power cut — leaves the lock behind, and +`SingletonLock` names the hostname that held it. Chromium only takes over +a dead lock from its own hostname; after the box is renamed (setup's +`name=` runs `hostnamectl` just before restarting the kiosk) it refuses +the profile with "in use by another Chromium process on another +computer". The wrapper runs prep right before it execs the box's only +Chromium, so no live process can hold the lock then. + A missing file (fresh profile) is a no-op; invalid JSON is reported and left alone, and prep never blocks the start (the wrapper runs it best effort). Together with the existing `--disable-session-crashed-bubble` diff --git a/packaging/root/usr/lib/syncphony-box/kiosk b/packaging/root/usr/lib/syncphony-box/kiosk index 2f95d62..83a051b 100755 --- a/packaging/root/usr/lib/syncphony-box/kiosk +++ b/packaging/root/usr/lib/syncphony-box/kiosk @@ -55,9 +55,10 @@ fi mkdir -p "${DATA_DIR}" -# A power cut leaves Chromium's own "exited uncleanly" bookkeeping behind, -# which can surface as a restore prompt. Clear it before every start — -# best effort: a failure never blocks the kiosk. +# A power cut or a kill leaves Chromium's own "exited uncleanly" +# bookkeeping behind (a restore prompt), and its profile lock (a "profile +# in use on another computer" error once the hostname changes). Clear both +# before every start — best effort: a failure never blocks the kiosk. "${BOXD_BIN}" profile prep "${DATA_DIR}" 2>/dev/null || true # GPU flags: the Pi's vc4/v3d GPU is on Chromium's software-rendering