Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
130 changes: 130 additions & 0 deletions infra/instance/instance.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
package instance

import (
"bufio"
"log"
"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.
// 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)
}
}()

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())
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 {
dialer := net.Dialer{Timeout: connTimeout}

conn, err := dialer.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)
}
}()

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
}
24 changes: 24 additions & 0 deletions infra/instance/socket_unix.go
Original file line number Diff line number Diff line change
@@ -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"
}
25 changes: 25 additions & 0 deletions infra/instance/socket_windows.go
Original file line number Diff line number Diff line change
@@ -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"
}
21 changes: 17 additions & 4 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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.")
return
}

// Start pprof server for profiling (only when --pprof flag is passed)
if *pprofEnabled {
go func() {
Expand All @@ -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)

Expand Down Expand Up @@ -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 {
Expand Down
Loading