Skip to content

Commit 3e83e5f

Browse files
committed
Re-exec the new binary after an in-place self-upgrade
When the auto-updater ran 'brew upgrade' (or npm/go/docker) mid-command, it replaced the binary on disk but kept executing the old image already loaded in memory. So the very run that triggered the upgrade still used pre-upgrade logic (e.g. a service-detection fix that had just shipped), confusing users who saw a fixed bug reappear until the next invocation. After a successful upgrade, re-exec the freshly installed binary in place (syscall.Exec on Unix), preserving the user's original args, so their command continues on the new code. Resolve the new binary via a fresh PATH lookup since os.Executable() can point at the old install dir the upgrade deleted (e.g. Homebrew Cellar/<old-version>). A PREFLIGHT_NO_UPDATE_CHECK guard set on the re-exec'd process prevents a re-prompt loop and lets users opt out. Windows has no exec(2) equivalent, so it falls back to asking the user to re-run.
1 parent c87f4ce commit 3e83e5f

3 files changed

Lines changed: 93 additions & 8 deletions

File tree

cmd/reexec_unix.go

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
//go:build !windows
2+
3+
package cmd
4+
5+
import (
6+
"os"
7+
"syscall"
8+
)
9+
10+
// execNewBinary replaces the current process image with the freshly installed
11+
// binary, preserving the user's original arguments. On success it does not
12+
// return (the new program takes over); it only returns on error so the caller
13+
// can fall back to asking the user to re-run.
14+
func execNewBinary() error {
15+
bin, err := resolveNewBinary()
16+
if err != nil {
17+
return err
18+
}
19+
env := append(os.Environ(), noUpdateCheckEnv+"=1")
20+
return syscall.Exec(bin, os.Args, env)
21+
}

cmd/reexec_windows.go

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
//go:build windows
2+
3+
package cmd
4+
5+
import "errors"
6+
7+
// execNewBinary is unsupported on Windows: there is no exec(2) equivalent that
8+
// replaces the running process image in place. The caller falls back to asking
9+
// the user to re-run their command on the new version.
10+
func execNewBinary() error {
11+
return errors.New("in-place re-exec is not supported on windows")
12+
}

cmd/update.go

Lines changed: 60 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,11 @@ import (
1616

1717
const updateCheckInterval = 24 * time.Hour
1818

19+
// noUpdateCheckEnv disables the update check entirely when set to a non-empty
20+
// value. It is also set on the re-exec'd process after an in-place upgrade so
21+
// the freshly launched binary doesn't immediately prompt again.
22+
const noUpdateCheckEnv = "PREFLIGHT_NO_UPDATE_CHECK"
23+
1924
type githubRelease struct {
2025
TagName string `json:"tag_name"`
2126
}
@@ -28,6 +33,12 @@ func CheckForUpdates() {
2833
return
2934
}
3035

36+
// Allow opting out, and avoid re-prompting on the process we re-exec
37+
// after an in-place upgrade.
38+
if os.Getenv(noUpdateCheckEnv) != "" {
39+
return
40+
}
41+
3142
if !shouldCheckForUpdate() {
3243
return
3344
}
@@ -69,7 +80,12 @@ func CheckForUpdates() {
6980
// Require explicit Y; default (empty input) is No.
7081
response = strings.TrimSpace(strings.ToLower(response))
7182
if response == "y" || response == "yes" {
72-
runUpgrade(upgradeCmd)
83+
if runUpgrade(upgradeCmd) {
84+
// The current process still holds the pre-upgrade binary in
85+
// memory; without handing off, the rest of this invocation
86+
// runs stale logic. This does not return on success.
87+
relaunchAfterUpgrade()
88+
}
7389
} else {
7490
fmt.Printf(" To upgrade later: %s\n", upgradeCmd)
7591
}
@@ -111,34 +127,70 @@ func markUpdateChecked() {
111127
_ = os.WriteFile(checkFile, []byte(time.Now().UTC().Format(time.RFC3339)), 0644)
112128
}
113129

114-
// runUpgrade executes an already-vetted upgrade command. The caller is
115-
// responsible for gating any `curl | sh` style commands, which this
116-
// function will refuse for safety.
117-
func runUpgrade(upgradeCmd string) {
130+
// runUpgrade executes an already-vetted upgrade command and reports whether it
131+
// succeeded. The caller is responsible for gating any `curl | sh` style
132+
// commands, which this function will refuse for safety.
133+
func runUpgrade(upgradeCmd string) bool {
118134
if strings.Contains(upgradeCmd, "|") {
119135
// Defense in depth: CheckForUpdates is supposed to filter these
120136
// out already, but make sure we never pipe untrusted bytes into
121137
// a shell from this code path.
122138
fmt.Printf(" ✗ Refusing to auto-run piped shell command: %s\n", upgradeCmd)
123-
return
139+
return false
124140
}
125141

126142
fmt.Printf(" Running: %s\n", upgradeCmd)
127143
parts := strings.Fields(upgradeCmd)
128144
if len(parts) == 0 {
129145
fmt.Println(" ✗ Could not determine upgrade command")
130-
return
146+
return false
131147
}
132148

133149
cmd := exec.Command(parts[0], parts[1:]...)
134150
cmd.Stdout = os.Stdout
135151
cmd.Stderr = os.Stderr
136152
if err := cmd.Run(); err != nil {
137153
fmt.Printf(" ✗ Upgrade failed: %v\n", err)
138-
return
154+
return false
139155
}
140156

141157
fmt.Println(" ✓ Upgrade complete!")
158+
return true
159+
}
160+
161+
// relaunchAfterUpgrade hands the user's original command off to the
162+
// just-installed binary. The current process still has the pre-upgrade binary
163+
// loaded in memory, so without this the rest of the invocation keeps running
164+
// stale logic (e.g. service detection from before a fix). On Unix this re-execs
165+
// in place and never returns; if re-exec is unsupported (Windows) or fails, it
166+
// prints a re-run hint and exits so we never silently continue on old code.
167+
func relaunchAfterUpgrade() {
168+
fmt.Println(" ↻ Restarting with the new version...")
169+
fmt.Println()
170+
if err := execNewBinary(); err != nil {
171+
fmt.Println(" Please re-run your command to use the new version.")
172+
}
173+
os.Exit(0)
174+
}
175+
176+
// resolveNewBinary returns an absolute path to the preflight binary to re-exec
177+
// after an upgrade. It prefers a fresh PATH lookup of the invoked name because
178+
// os.Executable() can point at the previous version's install directory (e.g. a
179+
// Homebrew Cellar/<old-version> path) that the upgrade just deleted; the PATH
180+
// entry (a symlink/shim) already targets the newly installed binary.
181+
func resolveNewBinary() (string, error) {
182+
if name := os.Args[0]; name != "" {
183+
if bin, err := exec.LookPath(name); err == nil {
184+
if abs, err := filepath.Abs(bin); err == nil {
185+
return abs, nil
186+
}
187+
}
188+
}
189+
exe, err := os.Executable()
190+
if err != nil {
191+
return "", err
192+
}
193+
return filepath.Abs(exe)
142194
}
143195

144196
func fetchLatestVersion() (string, error) {

0 commit comments

Comments
 (0)