//go:build linux && !bindings // NB the !bindings tag: Wails generates the TypeScript bindings by BUILDING AND // RUNNING this binary. With the guard active, a normal OpsLog already running on // the dev machine holds the lock, the generator's process exits instantly, and // no bindings are produced. Excluding the guard from that build keeps generation // working while shipping builds still get it. package main import ( "os" "path/filepath" "strconv" "syscall" "time" "hamlog/internal/applog" ) // The Linux half of the single-instance guard. Windows uses a named mutex; here // it is an advisory lock (flock) held on a file for as long as the process // lives. // // A lock and not a pid file, because a pid file is wrong exactly when it // matters: OpsLog killed by the OOM killer, or crashing on a bad rig response, // leaves its pid behind and every later launch refuses to start. The kernel // drops an flock when the process ends however it ends, so there is no stale // state to clean up and no "delete this file to start again" for the operator // to discover. var instanceLock *os.File // instanceLockPath prefers XDG_RUNTIME_DIR (/run/user/1000) — per user, and // emptied when the session ends, which is what a runtime lock wants. The cache // directory is the fallback for the sessions that do not set it (a bare TTY, an // ssh -X login). func instanceLockPath() string { dir := os.Getenv("XDG_RUNTIME_DIR") if dir == "" { dir = bootLogDir() } dir = filepath.Join(dir, "OpsLog") if err := os.MkdirAll(dir, 0o700); err != nil { return "" } return filepath.Join(dir, "instance.lock") } // acquireSingleInstance reports whether this process now owns the instance // lock. Safe to call repeatedly: the retry loop in acquireInstance does, and a // second flock on a second descriptor of the same file would conflict with the // one we already hold. func acquireSingleInstance() bool { if instanceLock != nil { return true } path := instanceLockPath() if path == "" { applog.Printf("single-instance: no writable folder for the lock — the guard is off for this run") return true // fail open: refusing to start is worse than a possible duplicate } f, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR, 0o600) if err != nil { applog.Printf("single-instance: cannot open %s (%v) — the guard is off for this run", path, err) return true } if err := syscall.Flock(int(f.Fd()), syscall.LOCK_EX|syscall.LOCK_NB); err != nil { _ = f.Close() return false // another OpsLog holds it } // The pid is written for the operator's benefit, not ours — it is what a // "which process is holding this?" question needs. The lock itself is the // kernel's, and does not depend on the contents. _ = f.Truncate(0) _, _ = f.WriteString(strconv.Itoa(os.Getpid()) + "\n") _ = f.Sync() instanceLock = f // deliberately never closed: closing releases the lock return true } // waitForProcessExit waits for pid to disappear, up to timeout, and reports // whether it did. Signal 0 asks the kernel "does this process exist?" without // touching it. // // EPERM means it exists and belongs to somebody else — still running, as far as // the caller is concerned. Only ESRCH is gone. func waitForProcessExit(pid int, timeout time.Duration) bool { if pid <= 0 { return true } deadline := time.Now().Add(timeout) for time.Now().Before(deadline) { if err := syscall.Kill(pid, 0); err == syscall.ESRCH { return true } time.Sleep(100 * time.Millisecond) } return syscall.Kill(pid, 0) == syscall.ESRCH }