fix(linux): the update relaunch is per-platform again
Restoring the PowerShell helper put it in update.go, which is shared — so a Linux build would have tried to run `powershell` to relaunch itself. It compiled and vetted cleanly for linux/amd64, which is exactly why it needed catching before somebody built it: the fault only shows on a real update, on a machine that has no PowerShell. scheduleRelaunch now lives in the platform files. Windows keeps the helper. Linux starts the new binary directly, which is right there and not a compromise: nothing holds an executable open while it runs, so the swap has already succeeded, and there is no mutex to race — the single-instance guard is an flock the dying process releases as it exits, and the new one waits for our pid first. The two guards were looking at the old location and had to follow: the Wait-Process/Start-Process check moves into relaunch_windows_test.go where it belongs, and TestEveryRelaunchPassesItsPid now scans updateswap_linux.go too — the direct spawn moved there, and without it the test would have gone quiet again. Checked from Windows, as BUILDING-LINUX.md says is done at every release: GOOS=linux go build ./... and go vet ./... both clean.
This commit is contained in:
@@ -1,9 +1,7 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"os"
|
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
|
||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -31,28 +29,3 @@ func TestRelaunchCmdPassesItsArgumentsAndFolder(t *testing.T) {
|
|||||||
t.Errorf("Dir = %q, want the executable's folder", cmd.Dir)
|
t.Errorf("Dir = %q, want the executable's folder", cmd.Dir)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// The update relaunch goes through a helper that OUTLIVES this process.
|
|
||||||
//
|
|
||||||
// Two rewrites started the new exe from here instead, and both left operators
|
|
||||||
// with no window after an update: the launch then happens while this process is
|
|
||||||
// still alive and still holds the mutex. The helper waits for our pid first.
|
|
||||||
// Restored from before 0430aab and pinned here so a third rewrite has to argue
|
|
||||||
// with the two reports rather than rediscover them.
|
|
||||||
func TestUpdateRelaunchWaitsForUsFromOutside(t *testing.T) {
|
|
||||||
src, err := os.ReadFile("update.go")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("read update.go: %v", err)
|
|
||||||
}
|
|
||||||
got := string(src)
|
|
||||||
for _, want := range []string{"Wait-Process -Id", "Start-Process -FilePath"} {
|
|
||||||
if !strings.Contains(got, want) {
|
|
||||||
t.Errorf("update.go no longer contains %q — the relaunch must wait for this process from outside it", want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Start-Process shows the new window normally. A direct exec.Command(exe…)
|
|
||||||
// here is the shape that broke it, twice.
|
|
||||||
if strings.Contains(got, "exec.Command(exe") {
|
|
||||||
t.Error("update.go starts the new exe directly again")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -3,7 +3,9 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -48,3 +50,28 @@ func TestRelaunchIsDetached(t *testing.T) {
|
|||||||
t.Error("CREATE_NEW_PROCESS_GROUP is missing: a cleanup aimed at the old instance can reach the new one")
|
t.Error("CREATE_NEW_PROCESS_GROUP is missing: a cleanup aimed at the old instance can reach the new one")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The update relaunch goes through a helper that OUTLIVES this process.
|
||||||
|
//
|
||||||
|
// Two rewrites started the new exe from inside the dying one instead, and both
|
||||||
|
// left operators with no window after an update: the launch then happens while
|
||||||
|
// this process is still alive and still holds the mutex. The helper waits for
|
||||||
|
// our pid first. Restored from before 0430aab and pinned here so a third
|
||||||
|
// rewrite has to argue with the two reports rather than rediscover them.
|
||||||
|
func TestWindowsUpdateRelaunchWaitsForUsFromOutside(t *testing.T) {
|
||||||
|
src, err := os.ReadFile("updateswap_windows.go")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read updateswap_windows.go: %v", err)
|
||||||
|
}
|
||||||
|
got := string(src)
|
||||||
|
for _, want := range []string{"Wait-Process -Id", "Start-Process -FilePath"} {
|
||||||
|
if !strings.Contains(got, want) {
|
||||||
|
t.Errorf("the relaunch no longer contains %q — it must wait for this process from outside it", want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Start-Process shows the new window normally. A direct exec.Command(exe…)
|
||||||
|
// is the shape that broke it, twice.
|
||||||
|
if strings.Contains(got, "exec.Command(exe") {
|
||||||
|
t.Error("the Windows relaunch starts the new exe directly again")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ func TestEveryRelaunchPassesItsPid(t *testing.T) {
|
|||||||
// quietly matched nothing at all — a guard that passes because it looks
|
// quietly matched nothing at all — a guard that passes because it looks
|
||||||
// nowhere.
|
// nowhere.
|
||||||
spawn := regexp.MustCompile(`(exec\.Command|relaunchCmd)\(exe, "--(post-update|relaunch)"[^)]*\)`)
|
spawn := regexp.MustCompile(`(exec\.Command|relaunchCmd)\(exe, "--(post-update|relaunch)"[^)]*\)`)
|
||||||
for _, file := range []string{"update.go", "app.go"} {
|
for _, file := range []string{"update.go", "app.go", "updateswap_linux.go"} {
|
||||||
src, err := os.ReadFile(file)
|
src, err := os.ReadFile(file)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("read %s: %v", file, err)
|
t.Fatalf("read %s: %v", file, err)
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -214,51 +213,14 @@ func (a *App) DownloadAndApplyUpdate(url string) error {
|
|||||||
}
|
}
|
||||||
applog.Printf("update: installed new build, scheduling relaunch")
|
applog.Printf("update: installed new build, scheduling relaunch")
|
||||||
|
|
||||||
// A DETACHED, HIDDEN POWERSHELL waits for this process to exit and THEN
|
// How the new build gets started differs by platform — see
|
||||||
// starts the new exe. Restored, verbatim, from before 0430aab.
|
// scheduleRelaunch in updateswap_windows.go and updateswap_linux.go. On
|
||||||
//
|
// Windows it is a helper that outlives us; on Linux it is simply the new
|
||||||
// Two rewrites tried to do without it and both failed on real stations.
|
// binary, because nothing there holds an image open.
|
||||||
// Starting the new exe from here means launching it while this process is
|
if err := a.scheduleRelaunch(exe, dir); err != nil {
|
||||||
// still alive — and the comment on the original said exactly what that
|
|
||||||
// costs: "Launching the new exe directly while we're still alive raced the
|
|
||||||
// mutex and often left nothing running". Telling the new instance our pid so
|
|
||||||
// it could wait on the other side looked equivalent and was not; operators
|
|
||||||
// kept reporting no window after an update, on 0.27.23 and again after.
|
|
||||||
// What the helper has that neither rewrite did is that it OUTLIVES us: the
|
|
||||||
// launch happens after this process is completely gone, from a process that
|
|
||||||
// was never our child.
|
|
||||||
//
|
|
||||||
// The cost is known and accepted. Windows Defender removed 0.27.14 from a
|
|
||||||
// station as Trojan:Script/Wacatac.H!ml: an unsigned binary that replaces
|
|
||||||
// itself, clears the mark-of-the-web and spawns a windowless script to start
|
|
||||||
// another executable has the shape of a dropper, and the model reads shapes,
|
|
||||||
// not intentions. The operator's answer is to allow OpsLog in Defender. An
|
|
||||||
// updater that works and occasionally needs whitelisting beats one that
|
|
||||||
// leaves people with no running program.
|
|
||||||
//
|
|
||||||
// HideWindow here is right and is NOT the bug that made the updated OpsLog
|
|
||||||
// invisible: it hides POWERSHELL's console, which is the whole point. The
|
|
||||||
// new OpsLog is started by Start-Process, with a normal show.
|
|
||||||
quoted := strings.ReplaceAll(exe, "'", "''")
|
|
||||||
ps := fmt.Sprintf(
|
|
||||||
"Wait-Process -Id %d -ErrorAction SilentlyContinue; Start-Sleep -Milliseconds 400; Start-Process -FilePath '%s' -ArgumentList '--post-update'",
|
|
||||||
os.Getpid(), quoted)
|
|
||||||
cmd := exec.Command("powershell", "-NoProfile", "-WindowStyle", "Hidden", "-Command", ps)
|
|
||||||
cmd.Dir = dir
|
|
||||||
hideConsole(cmd)
|
|
||||||
if err := cmd.Start(); err != nil {
|
|
||||||
applog.Printf("update: the relaunch could not be started: %v", err)
|
applog.Printf("update: the relaunch could not be started: %v", err)
|
||||||
return fmt.Errorf("schedule relaunch: %w", err)
|
return err
|
||||||
}
|
}
|
||||||
// The HELPER's pid, so the log says the launcher was started and not just
|
|
||||||
// that we meant to. The new OpsLog logs its own arrival in startup.log; the
|
|
||||||
// two together tell "the helper never ran" from "it ran and the exe did not
|
|
||||||
// start", which have different causes.
|
|
||||||
applog.Printf("update: relaunch helper started as pid %d — it waits for this process (pid %d) to exit, then starts %s",
|
|
||||||
cmd.Process.Pid, os.Getpid(), filepath.Base(exe))
|
|
||||||
// Released rather than waited on: this process is about to exit, and a child
|
|
||||||
// that outlives its parent must not be left as a zombie handle.
|
|
||||||
_ = cmd.Process.Release()
|
|
||||||
if a.ctx != nil {
|
if a.ctx != nil {
|
||||||
wruntime.Quit(a.ctx)
|
wruntime.Quit(a.ctx)
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
"hamlog/internal/applog"
|
"hamlog/internal/applog"
|
||||||
)
|
)
|
||||||
@@ -40,3 +41,23 @@ func (a *App) scheduleDeferredSwap(exe, pending string) error {
|
|||||||
applog.Printf("update: installed %s over %s after the staging rename failed", filepath.Base(pending), filepath.Base(exe))
|
applog.Printf("update: installed %s over %s after the staging rename failed", filepath.Base(pending), filepath.Base(exe))
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// scheduleRelaunch starts the new build. On Linux that is simply the new binary.
|
||||||
|
//
|
||||||
|
// None of the Windows machinery applies: nothing holds an executable open while
|
||||||
|
// it runs, so the swap above already succeeded, and there is no mutex to race —
|
||||||
|
// the single-instance guard is an flock the dying process releases as it exits,
|
||||||
|
// and the new one waits for our pid before trying it. No helper, no script, and
|
||||||
|
// nothing that needs to outlive us.
|
||||||
|
func (a *App) scheduleRelaunch(exe, dir string) error {
|
||||||
|
cmd := relaunchCmd(exe, "--post-update", "--wait-pid", strconv.Itoa(os.Getpid()))
|
||||||
|
if err := cmd.Start(); err != nil {
|
||||||
|
return fmt.Errorf("schedule relaunch: %w", err)
|
||||||
|
}
|
||||||
|
applog.Printf("update: relaunch started as pid %d — it waits for this process (pid %d) to exit",
|
||||||
|
cmd.Process.Pid, os.Getpid())
|
||||||
|
// Released rather than waited on: this process is about to exit, and a child
|
||||||
|
// that outlives its parent must not be left as a zombie.
|
||||||
|
_ = cmd.Process.Release()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -22,6 +22,49 @@ func clearDownloadMark(path string) { _ = os.Remove(path + ":Zone.Identifier") }
|
|||||||
// makeExecutable is a no-op on Windows, where the extension decides.
|
// makeExecutable is a no-op on Windows, where the extension decides.
|
||||||
func makeExecutable(path string) error { return nil }
|
func makeExecutable(path string) error { return nil }
|
||||||
|
|
||||||
|
// scheduleRelaunch starts the new build once THIS process is gone.
|
||||||
|
//
|
||||||
|
// A detached, hidden PowerShell waits for our pid and then launches the exe.
|
||||||
|
// Restored verbatim from before 0430aab, after two rewrites that started the
|
||||||
|
// new exe from inside the dying process both failed on real stations — the
|
||||||
|
// original's own comment had already said why: "Launching the new exe
|
||||||
|
// directly while we're still alive raced the mutex and often left nothing
|
||||||
|
// running." Telling the new instance our pid so it could wait on the other
|
||||||
|
// side looked equivalent and was not. What the helper has that neither
|
||||||
|
// rewrite did is that it OUTLIVES us.
|
||||||
|
//
|
||||||
|
// The cost is known and accepted: Windows Defender removed 0.27.14 from a
|
||||||
|
// station as Trojan:Script/Wacatac.H!ml, because an unsigned binary that
|
||||||
|
// replaces itself, clears the mark-of-the-web and spawns a windowless script
|
||||||
|
// to start another executable has the shape of a dropper. Allowing OpsLog in
|
||||||
|
// Defender beats an updater that leaves people with no running program.
|
||||||
|
//
|
||||||
|
// HideWindow is right here and is NOT the bug that made the updated OpsLog
|
||||||
|
// invisible: it hides POWERSHELL's console, which is the point, while
|
||||||
|
// Start-Process shows the new window normally.
|
||||||
|
func (a *App) scheduleRelaunch(exe, dir string) error {
|
||||||
|
quoted := strings.ReplaceAll(exe, "'", "''")
|
||||||
|
ps := fmt.Sprintf(
|
||||||
|
"Wait-Process -Id %d -ErrorAction SilentlyContinue; Start-Sleep -Milliseconds 400; Start-Process -FilePath '%s' -ArgumentList '--post-update'",
|
||||||
|
os.Getpid(), quoted)
|
||||||
|
cmd := exec.Command("powershell", "-NoProfile", "-WindowStyle", "Hidden", "-Command", ps)
|
||||||
|
cmd.Dir = dir
|
||||||
|
hideConsole(cmd)
|
||||||
|
if err := cmd.Start(); err != nil {
|
||||||
|
return fmt.Errorf("schedule relaunch: %w", err)
|
||||||
|
}
|
||||||
|
// The HELPER's pid, so the log says the launcher was started and not just
|
||||||
|
// that we meant to. The new OpsLog logs its own arrival in startup.log; the
|
||||||
|
// two together tell "the helper never ran" from "it ran and the exe did not
|
||||||
|
// start", which have different causes.
|
||||||
|
applog.Printf("update: relaunch helper started as pid %d — it waits for this process (pid %d) to exit, then starts %s",
|
||||||
|
cmd.Process.Pid, os.Getpid(), filepath.Base(exe))
|
||||||
|
// Released rather than waited on: this process is about to exit, and a child
|
||||||
|
// that outlives its parent must not be left as a zombie handle.
|
||||||
|
_ = cmd.Process.Release()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// scheduleDeferredSwap hands the exe swap to a detached helper that runs AFTER
|
// scheduleDeferredSwap hands the exe swap to a detached helper that runs AFTER
|
||||||
// this process is gone.
|
// this process is gone.
|
||||||
//
|
//
|
||||||
|
|||||||
Reference in New Issue
Block a user