Files
OpsLog/singleinstance_windows.go
T
rouggy e0b110392a fix(update): wait for the old process, not for a fixed window
The relaunch after an update stopped working, and the regression is mine:
removing the PowerShell helper — which is what Defender was reading as a
dropper — also removed the wait it was doing. Nothing took over the job.

The numbers made it certain rather than unlucky. The instance being
replaced is allowed THIRTY seconds to shut down (armExitWatchdog forces
it out at that point) because it closes a remote logbook, a CAT session
and sometimes a backup. The new instance was patient with the
single-instance mutex for TWENTY. On any station where shutting down ran
past that, the new process gave up and exited in silence: no window after
an update, and the previous OpsLog still in the task manager. Exactly the
report.

Both relaunch paths now pass --wait-pid, and the new process waits on
that process's handle — a plain kernel wait, which ends the instant the
old one ends, however long or short that is, and looks nothing like a
script starting another program. The mutex retry stays as a backstop and
goes to forty-five seconds, so it is longer than the wait it exists for
rather than shorter.

And when the old process really has not gone, the message says that
instead of "OpsLog is already running" — after an update the operator did
not start a second copy, and what they need to know is which one to
close.

A test keeps the two spawn sites honest: a relaunch added without
--wait-pid is this bug again.
2026-09-08 09:18:06 +02:00

104 lines
4.0 KiB
Go

//go:build windows && !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 mutex, 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 (
"errors"
"time"
"unsafe"
"golang.org/x/sys/windows"
)
// singleInstanceName is a per-session named mutex. The Windows kernel releases
// it automatically when the owning process dies (even on a crash), so a
// lingering/zombie OpsLog can't permanently block future launches — killing it
// frees the name at once. Session-local (no "Global\\") = one instance per
// logged-in desktop, which is what we want.
const singleInstanceName = "OpsLog-SingleInstance-Mutex"
// acquireSingleInstance creates the named mutex. Returns ok=false when another
// OpsLog already holds it (this instance should exit); on the way out it brings
// the existing window to the front so a double-click just refocuses OpsLog
// instead of spawning a duplicate that fights over the CAT / antenna.
//
// The mutex handle is deliberately never closed — it must live for the whole
// process lifetime; the OS reclaims it on exit.
func acquireSingleInstance() (ok bool) {
namePtr, err := windows.UTF16PtrFromString(singleInstanceName)
if err != nil {
return true // never block launch on an unexpected error
}
kernel32 := windows.NewLazySystemDLL("kernel32.dll")
createMutex := kernel32.NewProc("CreateMutexW")
// CreateMutexW(lpSecurityAttributes=NULL, bInitialOwner=FALSE, lpName)
h, _, callErr := createMutex.Call(0, 0, uintptr(unsafe.Pointer(namePtr)))
if h == 0 {
return true // couldn't create the mutex → don't block the app
}
if errors.Is(callErr, windows.ERROR_ALREADY_EXISTS) {
focusExistingWindow()
return false
}
return true
}
// focusExistingWindow finds the running OpsLog window by its title and restores
// + foregrounds it. Best-effort; failures are silently ignored.
func focusExistingWindow() {
user32 := windows.NewLazySystemDLL("user32.dll")
findWindow := user32.NewProc("FindWindowW")
setForeground := user32.NewProc("SetForegroundWindow")
showWindow := user32.NewProc("ShowWindow")
title, err := windows.UTF16PtrFromString("OpsLog")
if err != nil {
return
}
hwnd, _, _ := findWindow.Call(0, uintptr(unsafe.Pointer(title)))
if hwnd == 0 {
return
}
const swRestore = 9 // SW_RESTORE — un-minimise if needed
showWindow.Call(hwnd, swRestore)
setForeground.Call(hwnd)
}
// waitForProcessExit blocks until the process with this pid is gone, or the
// timeout runs out. Reports whether it actually went.
//
// This is what the auto-update relaunch needs, and it is the piece that was
// lost when the PowerShell helper went. The old instance is allowed thirty
// seconds to shut down (see armExitWatchdog) — it closes a remote logbook, a
// CAT session, sometimes a backup — while the new one was only patient with the
// mutex for twenty. On a station where shutdown took longer than that, the new
// process gave up and exited, and the operator was left with the old one still
// running and no new window: exactly the report.
//
// Waiting on a handle rather than sleeping a fixed time is also the honest
// version: it ends the instant the old process ends, however long or short that
// is, and it is a plain kernel wait — nothing that looks like a script starting
// another program.
func waitForProcessExit(pid int, timeout time.Duration) bool {
if pid <= 0 {
return true
}
h, err := windows.OpenProcess(windows.SYNCHRONIZE, false, uint32(pid))
if err != nil {
// Already gone, or not ours to wait on. Either way there is nothing to
// wait for — and refusing to launch over an unexpected permission error
// would be worse than starting.
return true
}
defer windows.CloseHandle(h)
ms := uint32(timeout / time.Millisecond)
ev, err := windows.WaitForSingleObject(h, ms)
return err == nil && ev == uint32(windows.WAIT_OBJECT_0)
}