From 8171ec16dee66cf1eef1cd5280afcaaa7af6217c Mon Sep 17 00:00:00 2001 From: Eugene Date: Mon, 23 Mar 2026 02:32:01 +0200 Subject: [PATCH 1/2] feat: single-instance guard with socket IPC to reopen existing window --- infra/instance/instance.go | 114 +++++++++++++++++++++++++++++++ infra/instance/socket_unix.go | 24 +++++++ infra/instance/socket_windows.go | 25 +++++++ main.go | 21 ++++-- 4 files changed, 180 insertions(+), 4 deletions(-) create mode 100644 infra/instance/instance.go create mode 100644 infra/instance/socket_unix.go create mode 100644 infra/instance/socket_windows.go diff --git a/infra/instance/instance.go b/infra/instance/instance.go new file mode 100644 index 0000000..77c7c30 --- /dev/null +++ b/infra/instance/instance.go @@ -0,0 +1,114 @@ +package instance + +import ( + "bufio" + "log" + "net" + "os" + "strings" + + "github.com/debrief-dev/debrief/infra/config" +) + +// TryAcquire attempts to become the single running instance. +// If another instance is already listening, it sends a "show" command and +// returns (false, nil) — the caller should exit. +// If this is the first instance, it starts a listener and returns +// (true, cleanup) where cleanup closes the listener and removes the socket. +func TryAcquire(windowSignalChan chan<- string) (acquired bool, cleanup func()) { + path := socketPath() + network := socketNetwork() + + // Try to connect to an existing instance. + if sendShow(network, path) { + return false, nil + } + + // No listener found — remove stale socket file and start listening. + if err := os.Remove(path); err != nil && !os.IsNotExist(err) { + log.Printf("instance: failed to remove stale socket %s: %v", path, err) + } + + ln, err := net.Listen(network, path) //nolint:noctx // no cancellation needed for a long-lived listener + if err != nil { + // Race: another instance grabbed the socket between our Dial and Listen. + // Retry connecting once. + if sendShow(network, path) { + return false, nil + } + + log.Printf("instance: failed to listen on %s: %v (continuing without single-instance guard)", path, err) + + return true, nil + } + + // Restrict socket permissions to owner only. + if err := os.Chmod(path, config.FilePermissions); err != nil { + log.Printf("instance: failed to chmod socket %s: %v", path, err) + } + + go acceptLoop(ln, windowSignalChan) + + return true, func() { + if err := ln.Close(); err != nil { + log.Printf("instance: failed to close listener: %v", err) + } + + if err := os.Remove(path); err != nil && !os.IsNotExist(err) { + log.Printf("instance: failed to remove socket %s: %v", path, err) + } + } +} + +// acceptLoop accepts connections and forwards "show" commands to the signal channel. +func acceptLoop(ln net.Listener, windowSignalChan chan<- string) { + for { + conn, err := ln.Accept() + if err != nil { + // Listener closed (normal shutdown). + return + } + + go handleConn(conn, windowSignalChan) + } +} + +// handleConn reads commands from a connection and forwards them. +func handleConn(conn net.Conn, windowSignalChan chan<- string) { + defer func() { + if err := conn.Close(); err != nil { + log.Printf("instance: failed to close connection: %v", err) + } + }() + + scanner := bufio.NewScanner(conn) + for scanner.Scan() { + cmd := strings.TrimSpace(scanner.Text()) + if cmd == "show" { + select { + case windowSignalChan <- "show": + default: + // Channel full — a signal is already pending. + } + } + } +} + +// sendShow tries to connect to an existing instance and send a "show" command. +// Returns true if the command was delivered successfully. +func sendShow(network, path string) bool { + conn, err := net.Dial(network, path) //nolint:noctx // one-shot connection, no cancellation needed + if err != nil { + return false + } + + defer func() { + if err := conn.Close(); err != nil { + log.Printf("instance: failed to close connection: %v", err) + } + }() + + _, err = conn.Write([]byte("show\n")) + + return err == nil +} diff --git a/infra/instance/socket_unix.go b/infra/instance/socket_unix.go new file mode 100644 index 0000000..050801b --- /dev/null +++ b/infra/instance/socket_unix.go @@ -0,0 +1,24 @@ +//go:build !windows + +package instance + +import ( + "log" + "path/filepath" + + "github.com/debrief-dev/debrief/infra/config" +) + +func socketPath() string { + dir, err := config.AppDirectory() + if err != nil { + log.Printf("instance: cannot determine app directory: %v, falling back to /tmp", err) + return "/tmp/debrief.sock" + } + + return filepath.Join(dir, "debrief.sock") +} + +func socketNetwork() string { + return "unix" +} diff --git a/infra/instance/socket_windows.go b/infra/instance/socket_windows.go new file mode 100644 index 0000000..5a59c64 --- /dev/null +++ b/infra/instance/socket_windows.go @@ -0,0 +1,25 @@ +//go:build windows + +package instance + +import ( + "log" + "os" + "path/filepath" + + "github.com/debrief-dev/debrief/infra/config" +) + +func socketPath() string { + dir, err := config.AppDirectory() + if err != nil { + log.Printf("instance: cannot determine app directory: %v", err) + return filepath.Join(os.TempDir(), "debrief.sock") + } + + return filepath.Join(dir, "debrief.sock") +} + +func socketNetwork() string { + return "unix" +} diff --git a/main.go b/main.go index da0d7bf..019c9d2 100644 --- a/main.go +++ b/main.go @@ -30,6 +30,7 @@ import ( "github.com/debrief-dev/debrief/infra/autostart" "github.com/debrief-dev/debrief/infra/config" "github.com/debrief-dev/debrief/infra/hotkey" + "github.com/debrief-dev/debrief/infra/instance" "github.com/debrief-dev/debrief/infra/platform" "github.com/debrief-dev/debrief/infra/tray" "github.com/debrief-dev/debrief/infra/window" @@ -80,6 +81,17 @@ func main() { log.Printf("Debrief started - version %s", config.AppVersion) } + // Create window signal channel for communication between tray/hotkey and window + // receiver drains faster than human can produce signals - 1 is sufficient + windowSignalChan := make(chan string, 1) + + // Single-instance check: if another instance is running, signal it to show and exit. + acquired, cleanupInstance := instance.TryAcquire(windowSignalChan) + if !acquired { + log.Println("Another instance is already running; signaled it to show. Exiting.") + os.Exit(0) + } + // Start pprof server for profiling (only when --pprof flag is passed) if *pprofEnabled { go func() { @@ -91,10 +103,6 @@ func main() { }() } - // Create window signal channel for communication between tray/hotkey and window - // receiver drains faster than human can produce signals - 1 is sufficient - windowSignalChan := make(chan string, 1) - // Quit signal - only true when explicitly quitting from tray shouldQuit := make(chan bool, 1) @@ -214,6 +222,11 @@ func main() { isFirstWindow := true quitApp := func() { + // Clean up single-instance socket before exiting. + if cleanupInstance != nil { + cleanupInstance() + } + // os.Exit does not run deferred functions, so flush the log file // explicitly to avoid losing buffered entries. if logFile != nil { From 4d5ac3899a644c076d3d270c603d32b121a6bcac Mon Sep 17 00:00:00 2001 From: Eugene Date: Mon, 23 Mar 2026 02:39:43 +0200 Subject: [PATCH 2/2] golangci-lint --- infra/instance/instance.go | 18 +++++++++++++++++- main.go | 2 +- 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/infra/instance/instance.go b/infra/instance/instance.go index 77c7c30..6e53e57 100644 --- a/infra/instance/instance.go +++ b/infra/instance/instance.go @@ -6,10 +6,14 @@ import ( "net" "os" "strings" + "time" "github.com/debrief-dev/debrief/infra/config" ) +// connTimeout bounds how long an accepted or dialed connection may be idle. +const connTimeout = 2 * time.Second + // TryAcquire attempts to become the single running instance. // If another instance is already listening, it sends a "show" command and // returns (false, nil) — the caller should exit. @@ -81,6 +85,11 @@ func handleConn(conn net.Conn, windowSignalChan chan<- string) { } }() + if err := conn.SetDeadline(time.Now().Add(connTimeout)); err != nil { + log.Printf("instance: failed to set connection deadline: %v", err) + return + } + scanner := bufio.NewScanner(conn) for scanner.Scan() { cmd := strings.TrimSpace(scanner.Text()) @@ -97,7 +106,9 @@ func handleConn(conn net.Conn, windowSignalChan chan<- string) { // sendShow tries to connect to an existing instance and send a "show" command. // Returns true if the command was delivered successfully. func sendShow(network, path string) bool { - conn, err := net.Dial(network, path) //nolint:noctx // one-shot connection, no cancellation needed + dialer := net.Dialer{Timeout: connTimeout} + + conn, err := dialer.Dial(network, path) //nolint:noctx // one-shot connection, no cancellation needed if err != nil { return false } @@ -108,6 +119,11 @@ func sendShow(network, path string) bool { } }() + if err := conn.SetDeadline(time.Now().Add(connTimeout)); err != nil { + log.Printf("instance: failed to set write deadline: %v", err) + return false + } + _, err = conn.Write([]byte("show\n")) return err == nil diff --git a/main.go b/main.go index 019c9d2..b00629b 100644 --- a/main.go +++ b/main.go @@ -89,7 +89,7 @@ func main() { acquired, cleanupInstance := instance.TryAcquire(windowSignalChan) if !acquired { log.Println("Another instance is already running; signaled it to show. Exiting.") - os.Exit(0) + return } // Start pprof server for profiling (only when --pprof flag is passed)