From cf304d86296c932476d439134f41b1f501167fdc Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 15 Oct 2026 23:15:35 +0200 Subject: [PATCH 1/3] Wait quietly for clipmenud's session lock; count the operator's clipmenud (issues 860, 862) --- .../cmd/clipmenu-tools/clipboard_test.go | 25 ++++++ .../cmd/clipmenu-tools/manifest_test.go | 77 +++++++++++++++++++ .../clipmenu/cmd/clipmenu-tools/session.go | 25 +++++- modules/clipmenu/module.json | 2 +- 4 files changed, 125 insertions(+), 4 deletions(-) diff --git a/modules/clipmenu/cmd/clipmenu-tools/clipboard_test.go b/modules/clipmenu/cmd/clipmenu-tools/clipboard_test.go index 69bc1d30..628a5527 100644 --- a/modules/clipmenu/cmd/clipmenu-tools/clipboard_test.go +++ b/modules/clipmenu/cmd/clipmenu-tools/clipboard_test.go @@ -5,6 +5,7 @@ import ( "errors" "os" "os/exec" + "os/user" "path/filepath" "strconv" "strings" @@ -225,3 +226,27 @@ func TestWithoutAStoreTheChangesSayWhy(t *testing.T) { t.Fatalf("an empty history: %+v, %v", h, err) } } + +// The tools can run as another account than the operator's; the daemon that counts is the operator's +// (issue 860). Here the operator is the test's own account and the tool runs as a different uid. +func TestCollectingIsTheOperatorsDaemonNotThatOfTheAccountTheToolRunsAs(t *testing.T) { + store(t, map[string]string{"first": "first"}, "first") + fakeProcess(t, nobody, "clipmenud") // owned by the test's uid: the operator's + u, err := user.Current() + if err != nil { + t.Skip(err) + } + t.Setenv("MESH_OPERATOR_ACCOUNT", u.Username) + old := toolUID + toolUID = func() int { return os.Getuid() + 1 } + t.Cleanup(func() { toolUID = old }) + h, err := History(10) + if err != nil || !h.Collecting { + t.Fatalf("the operator's clipmenud runs: %+v %v", h, err) + } + // without the operator named, the tool's own uid stands, and that account has no daemon + t.Setenv("MESH_OPERATOR_ACCOUNT", "") + if h, _ := History(10); h.Collecting { + t.Fatalf("another account's daemon counted: %+v", h) + } +} diff --git a/modules/clipmenu/cmd/clipmenu-tools/manifest_test.go b/modules/clipmenu/cmd/clipmenu-tools/manifest_test.go index 22378f46..ce2e40ba 100644 --- a/modules/clipmenu/cmd/clipmenu-tools/manifest_test.go +++ b/modules/clipmenu/cmd/clipmenu-tools/manifest_test.go @@ -7,6 +7,8 @@ import ( "os/exec" "reflect" "strings" + "sync" + "syscall" "testing" "time" ) @@ -145,3 +147,78 @@ func TestTheLauncherUsesTheManagersDisplayAndWaitsOtherwise(t *testing.T) { t.Fatalf("the display that arrived while waiting: %q %v", out, ok) } } + +// Run for real: while another clipmenud of the account holds the session lock the launcher waits without +// a word, and starts clipmenud once the lock is free (issue 862). +func TestTheLauncherWaitsQuietlyWhileAnotherClipmenudHoldsTheLock(t *testing.T) { + if _, err := exec.LookPath("flock"); err != nil { + t.Skip("flock is missing here") + } + m := readManifest(t) + script, _ := m.resource(t, "launcher")["content"].(string) + dir := t.TempDir() + body := strings.Replace(script, "exec clipmenud", `echo "started"`, 1) + file := dir + "/launcher" + if err := os.WriteFile(file, []byte(body), 0o755); err != nil { + t.Fatal(err) + } + store := dir + "/cm/clipmenu.6.op" + os.MkdirAll(store, 0o700) + lock := store + "/session_lock" + // the older clipmenud: it holds the lock the way clipmenud does, until it is killed + holder := exec.Command("flock", "-x", "-n", lock, "sleep", "60") + holder.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} + if err := holder.Start(); err != nil { + t.Fatal(err) + } + // flock's child shares the lock, so the whole group goes + release := func() { syscall.Kill(-holder.Process.Pid, syscall.SIGKILL); holder.Wait() } + defer release() + for i := 0; i < 50; i++ { + if probe := exec.Command("flock", "-n", lock, "true"); probe.Run() != nil { + break + } + time.Sleep(50 * time.Millisecond) + } + var out syncBuffer + cmd := exec.Command("sh", file) + cmd.Env = []string{"PATH=" + os.Getenv("PATH"), "HOME=" + dir, "DISPLAY=:7", "USER=op", "CM_DIR=" + dir + "/cm"} + cmd.Stdout, cmd.Stderr = &out, &out + if err := cmd.Start(); err != nil { + t.Fatal(err) + } + done := make(chan error, 1) + go func() { done <- cmd.Wait() }() + select { + case <-done: + t.Fatalf("the launcher went on while the lock was held: %q", out.String()) + case <-time.After(2500 * time.Millisecond): + } + if out.Len() != 0 { + t.Fatalf("the launcher spoke while it waited: %q", out.String()) + } + release() + select { + case <-done: + case <-time.After(5 * time.Second): + cmd.Process.Kill() + t.Fatal("the launcher did not start clipmenud after the lock was freed") + } + if got := strings.TrimSpace(out.String()); got != "started" { + t.Fatalf("after the lock was freed: %q", got) + } +} + +type syncBuffer struct { + mu sync.Mutex + b strings.Builder +} + +func (s *syncBuffer) Write(p []byte) (int, error) { + s.mu.Lock() + defer s.mu.Unlock() + return s.b.Write(p) +} + +func (s *syncBuffer) Len() int { s.mu.Lock(); defer s.mu.Unlock(); return s.b.Len() } +func (s *syncBuffer) String() string { s.mu.Lock(); defer s.mu.Unlock(); return s.b.String() } diff --git a/modules/clipmenu/cmd/clipmenu-tools/session.go b/modules/clipmenu/cmd/clipmenu-tools/session.go index dc21774a..06f4fe24 100644 --- a/modules/clipmenu/cmd/clipmenu-tools/session.go +++ b/modules/clipmenu/cmd/clipmenu-tools/session.go @@ -21,6 +21,7 @@ import ( "fmt" "os" "os/exec" + "os/user" "path/filepath" "sort" "strconv" @@ -386,13 +387,31 @@ func (c *capped) Write(p []byte) (int, error) { return c.Buffer.Write(p) } -// processesOf are the pids of this uid's processes whose command name is comm, oldest first. +// toolUID is the uid the tool runs as; a test replaces it to stand for a tool that runs as another +// account than the operator's. +var toolUID = os.Getuid + +// operatorUID is the uid of the operator's account, whose clipmenud and store these tools look at. +// The runtime serves the tools as an account that need not be the operator's and names the operator +// in MESH_OPERATOR_ACCOUNT; without it (or when it is unknown here) the tool's own uid stands. +func operatorUID() int { + if name := strings.TrimSpace(os.Getenv("MESH_OPERATOR_ACCOUNT")); name != "" { + if u, err := user.Lookup(name); err == nil { + if id, err := strconv.Atoi(u.Uid); err == nil { + return id + } + } + } + return toolUID() +} + +// processesOf are the pids of the operator account's processes whose command name is comm, oldest first. func processesOf(comm string) []int { entries, err := os.ReadDir(procRoot) if err != nil { return nil } - uid := os.Getuid() + uid := operatorUID() var pids []int for _, e := range entries { pid, err := strconv.Atoi(e.Name()) @@ -411,7 +430,7 @@ func processesOf(comm string) []int { return pids } -// signalAll sends sig to every process of this uid named comm, and answers the pids it reached. +// signalAll sends sig to every process of the operator account named comm, and answers the pids it reached. func signalAll(comm string, sig syscall.Signal) []int { var reached []int for _, pid := range processesOf(comm) { diff --git a/modules/clipmenu/module.json b/modules/clipmenu/module.json index c62950c2..7ed31549 100644 --- a/modules/clipmenu/module.json +++ b/modules/clipmenu/module.json @@ -75,7 +75,7 @@ "type": "file", "path": "/usr/local/lib/mesh-clipmenu/clipmenud-session", "mode": "0755", - "content": "#!/bin/sh\n# clipmenud as the operator's user unit starts it, written by the mesh (module clipmenu). Replaced at every push.\n#\n# The account's service manager starts before the X server does, and has a DISPLAY only once the session's\n# xinitrc has handed it over (module xorg). So this waits, quietly, until the display is known, and exits\n# only when the session ends with it: a quick exit would run the unit into its start limit. DISPLAY, when\n# the manager has it, wins. Without it, exactly one X socket names the display; none or several (a\n# second server beside the session) wait for the session's own to arrive.\n# A running process does not see what the session hands the manager later, so each pass asks the manager.\nuntil [ -n \"$DISPLAY\" ]; do\n\tfor pair in $(systemctl --user show-environment 2>/dev/null | grep -E '^(DISPLAY|XAUTHORITY)='); do\n\t\texport \"$pair\"\n\tdone\n\t[ -n \"$DISPLAY\" ] && break\n\tset -- /tmp/.X11-unix/X*\n\tif [ $# -eq 1 ] && [ -S \"$1\" ]; then\n\t\tDISPLAY=\":${1##*/X}\"\n\t\texport DISPLAY\n\telse\n\t\tsleep 1\n\tfi\ndone\nif [ -z \"$XAUTHORITY\" ] && [ -r \"$HOME/.Xauthority\" ]; then\n\tXAUTHORITY=\"$HOME/.Xauthority\"\n\texport XAUTHORITY\nfi\nPATH=\"/usr/local/lib/mesh-clipmenu:$PATH\"\nexport PATH\nexec clipmenud\n" + "content": "#!/bin/sh\n# clipmenud as the operator's user unit starts it, written by the mesh (module clipmenu). Replaced at every push.\n#\n# The account's service manager starts before the X server does, and has a DISPLAY only once the session's\n# xinitrc has handed it over (module xorg). So this waits, quietly, until the display is known, and exits\n# only when the session ends with it: a quick exit would run the unit into its start limit. DISPLAY, when\n# the manager has it, wins. Without it, exactly one X socket names the display; none or several (a\n# second server beside the session) wait for the session's own to arrive.\n# A running process does not see what the session hands the manager later, so each pass asks the manager.\nuntil [ -n \"$DISPLAY\" ]; do\n\tfor pair in $(systemctl --user show-environment 2>/dev/null | grep -E '^(DISPLAY|XAUTHORITY)='); do\n\t\texport \"$pair\"\n\tdone\n\t[ -n \"$DISPLAY\" ] && break\n\tset -- /tmp/.X11-unix/X*\n\tif [ $# -eq 1 ] && [ -S \"$1\" ]; then\n\t\tDISPLAY=\":${1##*/X}\"\n\t\texport DISPLAY\n\telse\n\t\tsleep 1\n\tfi\ndone\nif [ -z \"$XAUTHORITY\" ] && [ -r \"$HOME/.Xauthority\" ]; then\n\tXAUTHORITY=\"$HOME/.Xauthority\"\n\texport XAUTHORITY\nfi\n# clipmenud allows one daemon per account: it takes an exclusive flock on its session_lock file and exits\n# with status 2 when that is held. After a delivery without a new login, the session's older clipmenud (from\n# its xinitrc) still holds it, and a start would run the unit round its restart loop. So this waits, quietly,\n# until the lock is free: it looks at the same file clipmenud locks (the directory is clipmenud's own rule)\n# and lets go at once. Exit status 75 is only \"held\"; any other outcome starts clipmenud, which says its own\n# error. Another daemon winning the gap between look and start costs one restart, not a loop.\nlock=\"${CM_DIR:-${XDG_RUNTIME_DIR-${TMPDIR-/tmp}}}/clipmenu.6.$USER/session_lock\"\nwhile [ -e \"$lock\" ]; do\n\tflock -n -E 75 \"$lock\" true\n\t[ $? -eq 75 ] || break\n\tsleep 2\ndone\nPATH=\"/usr/local/lib/mesh-clipmenu:$PATH\"\nexport PATH\nexec clipmenud\n" }, { "id": "daemon", -- 2.54.0 From 21ebbffed469c41818270984dd21c68ed0cd6f54 Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 15 Oct 2026 23:18:22 +0200 Subject: [PATCH 2/3] Count clipmenud when it runs as a bash script (issue 860) --- .../cmd/clipmenu-tools/clipboard_test.go | 30 +++++-------- .../clipmenu/cmd/clipmenu-tools/session.go | 45 +++++++++---------- 2 files changed, 34 insertions(+), 41 deletions(-) diff --git a/modules/clipmenu/cmd/clipmenu-tools/clipboard_test.go b/modules/clipmenu/cmd/clipmenu-tools/clipboard_test.go index 628a5527..2be67364 100644 --- a/modules/clipmenu/cmd/clipmenu-tools/clipboard_test.go +++ b/modules/clipmenu/cmd/clipmenu-tools/clipboard_test.go @@ -5,7 +5,6 @@ import ( "errors" "os" "os/exec" - "os/user" "path/filepath" "strconv" "strings" @@ -227,26 +226,21 @@ func TestWithoutAStoreTheChangesSayWhy(t *testing.T) { } } -// The tools can run as another account than the operator's; the daemon that counts is the operator's -// (issue 860). Here the operator is the test's own account and the tool runs as a different uid. -func TestCollectingIsTheOperatorsDaemonNotThatOfTheAccountTheToolRunsAs(t *testing.T) { +// clipmenud is a bash script: its process is named "bash" and only its command line names clipmenud +// (issue 860: collecting was false while the daemon ran). +func TestCollectingSeesClipmenudRunAsABashScript(t *testing.T) { store(t, map[string]string{"first": "first"}, "first") - fakeProcess(t, nobody, "clipmenud") // owned by the test's uid: the operator's - u, err := user.Current() - if err != nil { - t.Skip(err) + fakeProcess(t, nobody, "bash") + cmdline := filepath.Join(procRoot, strconv.Itoa(nobody), "cmdline") + if err := os.WriteFile(cmdline, []byte("bash\x00/usr/bin/clipmenud\x00"), 0o644); err != nil { + t.Fatal(err) } - t.Setenv("MESH_OPERATOR_ACCOUNT", u.Username) - old := toolUID - toolUID = func() int { return os.Getuid() + 1 } - t.Cleanup(func() { toolUID = old }) - h, err := History(10) - if err != nil || !h.Collecting { - t.Fatalf("the operator's clipmenud runs: %+v %v", h, err) + if h, err := History(10); err != nil || !h.Collecting { + t.Fatalf("clipmenud runs as a bash script: %+v %v", h, err) } - // without the operator named, the tool's own uid stands, and that account has no daemon - t.Setenv("MESH_OPERATOR_ACCOUNT", "") + // another bash script is not the daemon + os.WriteFile(cmdline, []byte("bash\x00/usr/bin/other\x00"), 0o644) if h, _ := History(10); h.Collecting { - t.Fatalf("another account's daemon counted: %+v", h) + t.Fatalf("a bash script that is not clipmenud counted: %+v", h) } } diff --git a/modules/clipmenu/cmd/clipmenu-tools/session.go b/modules/clipmenu/cmd/clipmenu-tools/session.go index 06f4fe24..55d66b2b 100644 --- a/modules/clipmenu/cmd/clipmenu-tools/session.go +++ b/modules/clipmenu/cmd/clipmenu-tools/session.go @@ -21,7 +21,6 @@ import ( "fmt" "os" "os/exec" - "os/user" "path/filepath" "sort" "strconv" @@ -387,31 +386,13 @@ func (c *capped) Write(p []byte) (int, error) { return c.Buffer.Write(p) } -// toolUID is the uid the tool runs as; a test replaces it to stand for a tool that runs as another -// account than the operator's. -var toolUID = os.Getuid - -// operatorUID is the uid of the operator's account, whose clipmenud and store these tools look at. -// The runtime serves the tools as an account that need not be the operator's and names the operator -// in MESH_OPERATOR_ACCOUNT; without it (or when it is unknown here) the tool's own uid stands. -func operatorUID() int { - if name := strings.TrimSpace(os.Getenv("MESH_OPERATOR_ACCOUNT")); name != "" { - if u, err := user.Lookup(name); err == nil { - if id, err := strconv.Atoi(u.Uid); err == nil { - return id - } - } - } - return toolUID() -} - -// processesOf are the pids of the operator account's processes whose command name is comm, oldest first. +// processesOf are the pids of this uid's processes whose command name is comm, oldest first. func processesOf(comm string) []int { entries, err := os.ReadDir(procRoot) if err != nil { return nil } - uid := operatorUID() + uid := os.Getuid() var pids []int for _, e := range entries { pid, err := strconv.Atoi(e.Name()) @@ -422,7 +403,7 @@ func processesOf(comm string) []int { if owner, ok := ownerOf(dir); !ok || owner != uid { continue } - if readTrimmed(filepath.Join(dir, "comm")) == comm { + if readTrimmed(filepath.Join(dir, "comm")) == comm || runsScript(dir, comm) { pids = append(pids, pid) } } @@ -430,7 +411,25 @@ func processesOf(comm string) []int { return pids } -// signalAll sends sig to every process of the operator account named comm, and answers the pids it reached. +// runsScript is whether the process is an interpreter running the script named name: clipmenud is a +// bash script, so its comm is "bash" and only its command line, "bash /usr/bin/clipmenud", names it. +func runsScript(procDir, name string) bool { + raw, err := os.ReadFile(filepath.Join(procDir, "cmdline")) + if err != nil { + return false + } + args := strings.Split(strings.TrimRight(string(raw), "\x00"), "\x00") + if len(args) < 2 { + return false + } + switch filepath.Base(args[0]) { + case "bash", "sh", "dash", "zsh", "env": + return filepath.Base(args[1]) == name + } + return false +} + +// signalAll sends sig to every process of this uid named comm, and answers the pids it reached. func signalAll(comm string, sig syscall.Signal) []int { var reached []int for _, pid := range processesOf(comm) { -- 2.54.0 From 6112233649d3bf6524185125ec0d58a28c25e92d Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 15 Oct 2026 23:27:19 +0200 Subject: [PATCH 3/3] Probe clipmenud's lock with flock's default conflict status, which BusyBox shares (issue 862) --- modules/clipmenu/module.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modules/clipmenu/module.json b/modules/clipmenu/module.json index 7ed31549..7343b982 100644 --- a/modules/clipmenu/module.json +++ b/modules/clipmenu/module.json @@ -75,7 +75,7 @@ "type": "file", "path": "/usr/local/lib/mesh-clipmenu/clipmenud-session", "mode": "0755", - "content": "#!/bin/sh\n# clipmenud as the operator's user unit starts it, written by the mesh (module clipmenu). Replaced at every push.\n#\n# The account's service manager starts before the X server does, and has a DISPLAY only once the session's\n# xinitrc has handed it over (module xorg). So this waits, quietly, until the display is known, and exits\n# only when the session ends with it: a quick exit would run the unit into its start limit. DISPLAY, when\n# the manager has it, wins. Without it, exactly one X socket names the display; none or several (a\n# second server beside the session) wait for the session's own to arrive.\n# A running process does not see what the session hands the manager later, so each pass asks the manager.\nuntil [ -n \"$DISPLAY\" ]; do\n\tfor pair in $(systemctl --user show-environment 2>/dev/null | grep -E '^(DISPLAY|XAUTHORITY)='); do\n\t\texport \"$pair\"\n\tdone\n\t[ -n \"$DISPLAY\" ] && break\n\tset -- /tmp/.X11-unix/X*\n\tif [ $# -eq 1 ] && [ -S \"$1\" ]; then\n\t\tDISPLAY=\":${1##*/X}\"\n\t\texport DISPLAY\n\telse\n\t\tsleep 1\n\tfi\ndone\nif [ -z \"$XAUTHORITY\" ] && [ -r \"$HOME/.Xauthority\" ]; then\n\tXAUTHORITY=\"$HOME/.Xauthority\"\n\texport XAUTHORITY\nfi\n# clipmenud allows one daemon per account: it takes an exclusive flock on its session_lock file and exits\n# with status 2 when that is held. After a delivery without a new login, the session's older clipmenud (from\n# its xinitrc) still holds it, and a start would run the unit round its restart loop. So this waits, quietly,\n# until the lock is free: it looks at the same file clipmenud locks (the directory is clipmenud's own rule)\n# and lets go at once. Exit status 75 is only \"held\"; any other outcome starts clipmenud, which says its own\n# error. Another daemon winning the gap between look and start costs one restart, not a loop.\nlock=\"${CM_DIR:-${XDG_RUNTIME_DIR-${TMPDIR-/tmp}}}/clipmenu.6.$USER/session_lock\"\nwhile [ -e \"$lock\" ]; do\n\tflock -n -E 75 \"$lock\" true\n\t[ $? -eq 75 ] || break\n\tsleep 2\ndone\nPATH=\"/usr/local/lib/mesh-clipmenu:$PATH\"\nexport PATH\nexec clipmenud\n" + "content": "#!/bin/sh\n# clipmenud as the operator's user unit starts it, written by the mesh (module clipmenu). Replaced at every push.\n#\n# The account's service manager starts before the X server does, and has a DISPLAY only once the session's\n# xinitrc has handed it over (module xorg). So this waits, quietly, until the display is known, and exits\n# only when the session ends with it: a quick exit would run the unit into its start limit. DISPLAY, when\n# the manager has it, wins. Without it, exactly one X socket names the display; none or several (a\n# second server beside the session) wait for the session's own to arrive.\n# A running process does not see what the session hands the manager later, so each pass asks the manager.\nuntil [ -n \"$DISPLAY\" ]; do\n\tfor pair in $(systemctl --user show-environment 2>/dev/null | grep -E '^(DISPLAY|XAUTHORITY)='); do\n\t\texport \"$pair\"\n\tdone\n\t[ -n \"$DISPLAY\" ] && break\n\tset -- /tmp/.X11-unix/X*\n\tif [ $# -eq 1 ] && [ -S \"$1\" ]; then\n\t\tDISPLAY=\":${1##*/X}\"\n\t\texport DISPLAY\n\telse\n\t\tsleep 1\n\tfi\ndone\nif [ -z \"$XAUTHORITY\" ] && [ -r \"$HOME/.Xauthority\" ]; then\n\tXAUTHORITY=\"$HOME/.Xauthority\"\n\texport XAUTHORITY\nfi\n# clipmenud allows one daemon per account: it takes an exclusive flock on its session_lock file and exits\n# with status 2 when that is held. After a delivery without a new login, the session's older clipmenud (from\n# its xinitrc) still holds it, and a start would run the unit round its restart loop. So this waits, quietly,\n# until the lock is free: it looks at the same file clipmenud locks (the directory is clipmenud's own rule)\n# and lets go at once. Exit status 1 is \"held\" for util-linux and BusyBox flock alike (no -E: BusyBox lacks it); any other outcome starts clipmenud, which says its own\n# error. Another daemon winning the gap between look and start costs one restart, not a loop.\nlock=\"${CM_DIR:-${XDG_RUNTIME_DIR-${TMPDIR-/tmp}}}/clipmenu.6.$USER/session_lock\"\nwhile [ -e \"$lock\" ]; do\n\tflock -n \"$lock\" true\n\t[ $? -eq 1 ] || break\n\tsleep 2\ndone\nPATH=\"/usr/local/lib/mesh-clipmenu:$PATH\"\nexport PATH\nexec clipmenud\n" }, { "id": "daemon", -- 2.54.0