Files
OpsLog/singleinstance_linux.go
T
rouggyandClaude Opus 5 8b1dff581b feat(linux): the Go half of OpsLog builds for Linux
Measured rather than guessed: the whole repository was cross-compiled for
linux/amd64 and the gaps closed one by one. There were fewer than expected.

Flex and TCI were never Windows-specific — they carried //go:build windows by
inheritance and import nothing but net and gorilla/websocket. Untagged, no code
change. The two backends a Linux operator is most likely to own were already
portable.

Audio was 560 lines, not 2287: only devices.go and engine.go touch WASAPI, while
manager.go, recorder.go, wav.go and mp3.go were pure Go wearing the tag by
association. The whole platform surface is seven functions, now implemented a
second time on PulseAudio through github.com/jfreymuth/pulse — pure Go over the
server socket, so the no-cgo rule survives, and PipeWire answers the same
protocol. The fixed 16 kHz mono format and the server-side resampling mirror
what AUTOCONVERTPCM does on Windows, for the same reason.

OmniRig is the only real loss, and its backend still EXISTS off Windows rather
than being compiled out of app.go: a settings database is portable, so an
operator moving a profile across keeps "omnirig" saved and must be told to pick
a native backend instead of meeting a nil one.

The parts where Linux is not Windows, and where a compile-only stub would have
been a silent bug:

  - data dir: still beside the binary, but ~/.local/share/OpsLog/data when that
    folder belongs to the system — decided by trying the write, because /opt and
    /usr/local are writable on some stations and not others.
  - single instance: an flock, not a pid file. The kernel drops it however the
    process dies, so a crash leaves nothing to delete by hand. This is the guard
    that stops two instances fighting over the rig frequency.
  - update: simpler here. Unix renames over a running binary, so the deferred
    swap the Windows path needs a detached helper for is unreachable.
  - tasklist/taskkill become /proc and SIGTERM; the boot log moves out of /tmp,
    which is wiped exactly when the evidence is wanted.
  - serial ports sorted naturally: /dev/ttyUSB10 was landing between USB1 and
    USB2, the same trap COM10 fell into.

release.ps1 now cross-builds and vets for linux before it builds the exe, and
refuses the release if that fails — a port rots one unguarded x/sys/windows call
at a time.

Nothing has been executed on Linux yet: Wails needs webkit2gtk and cgo there, so
the binary must be built on Linux. scripts/linux-setup.sh checks the machine and
does it; BUILDING-LINUX.md is the manual version.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-09 10:21:27 +02:00

100 lines
3.5 KiB
Go

//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
}