Revert to the PowerShell relaunch helper, on the operator's call
Restored verbatim from before 0430aab:
Wait-Process -Id <pid>; Start-Sleep -Milliseconds 400;
Start-Process -FilePath <exe> -ArgumentList '--post-update'
Two rewrites tried to start the new exe from inside the dying process
and both failed on real stations — 0.27.23 and again after the SW_HIDE
fix. 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 is not. What the helper has that
neither rewrite did is that it OUTLIVES us — the launch happens once
this process is completely gone, from a process that was never our
child.
The Defender cost is known and accepted: 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
0.27.14 was removed from a station as Trojan:Script/Wacatac.H!ml. The
operator's decision is that whitelisting OpsLog beats an updater that
leaves people with no running program, and the changelog says so.
HideWindow stays on the PowerShell and is not the bug that made the
updated OpsLog invisible: it hides the helper's console, which is the
point, while Start-Process shows the new window normally.
relaunchCmd and its detachment stay for RestartApp, the database-switch
relaunch, which has never been reported broken.
Two tests kept honest: the update path must contain Wait-Process and
Start-Process and must not spawn the exe directly again, and
TestEveryRelaunchPassesItsPid had quietly stopped matching anything when
the spawn sites changed spelling — its pattern now covers relaunchCmd
too, so it guards RestartApp instead of passing vacuously.
This commit is contained in:
+2
-2
@@ -2,10 +2,10 @@
|
||||
{
|
||||
"version": "0.27.25",
|
||||
"en": [
|
||||
"Two more pieces of the old relaunch are back, for the operators still losing OpsLog after an update: it is started detached, and a breath is taken after the previous instance has actually gone."
|
||||
"The relaunch after an update goes back to the helper that waited for OpsLog to close before starting the new version. Two rewrites tried to do without it and both left some operators with no window. Windows Defender may flag it — allow OpsLog in Defender if it does."
|
||||
],
|
||||
"fr": [
|
||||
"Deux éléments de plus de l’ancienne relance sont rétablis, pour les opérateurs qui perdent encore OpsLog après une mise à jour : il est démarré détaché, et une pause est prise après la disparition effective de l’instance précédente."
|
||||
"La relance après une mise à jour revient à l’assistant qui attendait la fermeture d’OpsLog avant de démarrer la nouvelle version. Deux réécritures ont essayé de s’en passer et laissaient certains opérateurs sans fenêtre. Windows Defender peut le signaler — dans ce cas, autorisez OpsLog dans Defender."
|
||||
]
|
||||
},
|
||||
{
|
||||
|
||||
+17
-9
@@ -32,19 +32,27 @@ func TestRelaunchCmdPassesItsArgumentsAndFolder(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// hideConsole is right for the console tools and wrong for OpsLog, and the two
|
||||
// live a few lines apart. This is the guard that stops the update path
|
||||
// borrowing it again — which is how it broke the first time, when the
|
||||
// PowerShell helper was replaced by a direct exec.Command beside them.
|
||||
func TestUpdateRelaunchDoesNotHideTheWindow(t *testing.T) {
|
||||
// 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)
|
||||
}
|
||||
if strings.Contains(string(src), "hideConsole(") {
|
||||
t.Error("update.go calls hideConsole — a relaunch of OpsLog must not hide its window (see relaunch.go)")
|
||||
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)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(string(src), "relaunchCmd(exe,") {
|
||||
t.Error("update.go no longer relaunches through relaunchCmd, where that rule is written down")
|
||||
// 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")
|
||||
}
|
||||
}
|
||||
|
||||
+12
-7
@@ -25,15 +25,20 @@ func TestWaitPidArg(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// Every relaunch has to tell the new process which one to wait for.
|
||||
// A relaunch started from INSIDE this process has to tell the new one which
|
||||
// process to wait for.
|
||||
//
|
||||
// The auto-update relaunch lost that when its PowerShell helper was removed —
|
||||
// the helper had waited for the pid, and nothing took over the job — and an
|
||||
// operator was left with no window after an update and the previous OpsLog
|
||||
// still running. This keeps the two spawn sites honest: if a relaunch is added
|
||||
// without --wait-pid, it is the same bug again.
|
||||
// The update path no longer does that from inside: its PowerShell helper waits
|
||||
// for the pid and starts the exe once we are gone, which is what two rewrites
|
||||
// failed to reproduce. RestartApp still spawns directly, and if a relaunch is
|
||||
// ever added the same way without --wait-pid, it is the old bug again.
|
||||
func TestEveryRelaunchPassesItsPid(t *testing.T) {
|
||||
spawn := regexp.MustCompile(`exec\.Command\(exe, "--(post-update|relaunch)"[^)]*\)`)
|
||||
// Both spellings: a relaunch built inline with exec.Command, and one built
|
||||
// through relaunchCmd. The pattern used to name only the first, and when the
|
||||
// update path moved to a PowerShell helper and RestartApp to relaunchCmd it
|
||||
// quietly matched nothing at all — a guard that passes because it looks
|
||||
// nowhere.
|
||||
spawn := regexp.MustCompile(`(exec\.Command|relaunchCmd)\(exe, "--(post-update|relaunch)"[^)]*\)`)
|
||||
for _, file := range []string{"update.go", "app.go"} {
|
||||
src, err := os.ReadFile(file)
|
||||
if err != nil {
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -213,45 +214,48 @@ func (a *App) DownloadAndApplyUpdate(url string) error {
|
||||
}
|
||||
applog.Printf("update: installed new build, scheduling relaunch")
|
||||
|
||||
// THE NEW EXE STARTS ITSELF. No helper, no script.
|
||||
// A DETACHED, HIDDEN POWERSHELL waits for this process to exit and THEN
|
||||
// starts the new exe. Restored, verbatim, from before 0430aab.
|
||||
//
|
||||
// This used to go through a hidden PowerShell that waited for our process to
|
||||
// die and then launched the new image — which is, byte for byte, the shape of
|
||||
// a dropper: an unsigned binary replaces itself on disk, clears the
|
||||
// mark-of-the-web, and spawns a windowless PowerShell that starts another
|
||||
// executable. Windows Defender's machine-learning model reads that shape and
|
||||
// not our intentions, and an operator updating to 0.27.14 had OpsLog removed
|
||||
// under Trojan:Script/Wacatac.H!ml — the "Script/" being the PowerShell.
|
||||
// Two rewrites tried to do without it and both failed on real stations.
|
||||
// Starting the new exe from here means launching it while this process is
|
||||
// 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 wait it existed for still has to happen — it just happens on the other
|
||||
// side now. The new instance is told OUR pid and waits for this process to
|
||||
// end before taking the single-instance mutex.
|
||||
// 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.
|
||||
//
|
||||
// Waiting on the mutex alone was not enough: shutting down is allowed thirty
|
||||
// seconds here (armExitWatchdog), because it closes a remote logbook, a CAT
|
||||
// session and sometimes a backup, while the new instance was only patient
|
||||
// for twenty. On a station where that ran long, the new process gave up and
|
||||
// exited — leaving the old one still running and no new window, which is
|
||||
// precisely what the PowerShell helper never did: it waited for the pid,
|
||||
// however long it took.
|
||||
//
|
||||
// And relaunchCmd rather than a command built here, because the OTHER half
|
||||
// of the same report was this line calling hideConsole: SW_HIDE in the
|
||||
// STARTUPINFO, which Windows applies to the new process's first window. The
|
||||
// updated OpsLog started, took the mutex, and stayed invisible. See
|
||||
// relaunch.go — the fact belongs in one place, since two self-relaunches
|
||||
// differing by one line is how only one of them was broken.
|
||||
cmd := relaunchCmd(exe, "--post-update", "--wait-pid", strconv.Itoa(os.Getpid()))
|
||||
// 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)
|
||||
return fmt.Errorf("schedule relaunch: %w", err)
|
||||
}
|
||||
// The child's pid, named in BOTH logs — this one and the new instance's
|
||||
// startup.log, which records the pid it was told to wait for. Without the
|
||||
// pair there is no way to tell "the new instance never started" from "it
|
||||
// started and gave up waiting", and those have different causes.
|
||||
applog.Printf("update: relaunch started as pid %d, waiting for this one (pid %d) to exit",
|
||||
cmd.Process.Pid, os.Getpid())
|
||||
// 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()
|
||||
|
||||
Reference in New Issue
Block a user