fix: retry self-upgrade with binary rename on Linux/WSL to avoid ETXTBSY#21793
fix: retry self-upgrade with binary rename on Linux/WSL to avoid ETXTBSY#21793
Conversation
Co-authored-by: dsyme <7204669+dsyme@users.noreply.github.com>
gh aw upgrade failure on WSL due to file in use
pkg/cli/update_extension_check.go
Outdated
| updateExtensionCheckLog.Printf("Upgrading extension from %s to %s", currentVersion, latestVersion) | ||
| fmt.Fprintln(os.Stderr, console.FormatInfoMessage(fmt.Sprintf("Upgrading gh-aw extension from %s to %s...", currentVersion, latestVersion))) | ||
|
|
||
| // On Linux (including WSL), the kernel returns ETXTBSY when any process |
There was a problem hiding this comment.
@pelikhan wdyt, could you look at this? Seems kind of dodgy to delete outselves befor we have a clear understanding the upgrade will succeed?
There was a problem hiding this comment.
Replaced the os.Remove approach with os.Rename to a .bak backup in commit 87759d2. If gh extension upgrade succeeds, the backup is cleaned up; if it fails, the backup is automatically restored so the user is never left without gh-aw. The upgrade is only attempted after the rename — we don't proceed blindly without knowing the path is free.
There was a problem hiding this comment.
Pull request overview
Fixes gh aw upgrade failures on Linux/WSL where gh extension upgrade cannot overwrite the currently-running gh-aw executable due to ETXTBSY (“text file busy”).
Changes:
- Add Linux-only pre-upgrade logic to resolve the current executable path and
os.Remove()it before invokinggh extension upgrade. - Add debug logging around the pre-removal attempt to aid diagnosis.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
You can also share your feedback on Copilot code review. Take the survey.
pkg/cli/update_extension_check.go
Outdated
| // tries to open a currently-executing binary for writing. Removing the | ||
| // directory entry (unlink) first avoids this: the inode stays alive while | ||
| // this process is running, but the path is now free for gh to write the | ||
| // newly-downloaded binary. | ||
| if runtime.GOOS == "linux" { | ||
| if exe, err := os.Executable(); err == nil { | ||
| // Resolve any symlink so we remove the real file, not just a link. | ||
| if resolved, resolveErr := filepath.EvalSymlinks(exe); resolveErr == nil { | ||
| exe = resolved | ||
| } | ||
| if rmErr := os.Remove(exe); rmErr != nil { | ||
| updateExtensionCheckLog.Printf("Could not pre-remove executable before upgrade (upgrade may still fail): %v", rmErr) | ||
| } else { | ||
| updateExtensionCheckLog.Printf("Pre-removed executable to avoid ETXTBSY on Linux: %s", exe) |
pkg/cli/update_extension_check.go
Outdated
| if rmErr := os.Remove(exe); rmErr != nil { | ||
| updateExtensionCheckLog.Printf("Could not pre-remove executable before upgrade (upgrade may still fail): %v", rmErr) | ||
| } else { | ||
| updateExtensionCheckLog.Printf("Pre-removed executable to avoid ETXTBSY on Linux: %s", exe) | ||
| } |
pkg/cli/update_extension_check.go
Outdated
| // On Linux (including WSL), the kernel returns ETXTBSY when any process | ||
| // tries to open a currently-executing binary for writing. Removing the | ||
| // directory entry (unlink) first avoids this: the inode stays alive while | ||
| // this process is running, but the path is now free for gh to write the | ||
| // newly-downloaded binary. | ||
| if runtime.GOOS == "linux" { | ||
| if exe, err := os.Executable(); err == nil { | ||
| // Resolve any symlink so we remove the real file, not just a link. | ||
| if resolved, resolveErr := filepath.EvalSymlinks(exe); resolveErr == nil { | ||
| exe = resolved | ||
| } | ||
| if rmErr := os.Remove(exe); rmErr != nil { | ||
| updateExtensionCheckLog.Printf("Could not pre-remove executable before upgrade (upgrade may still fail): %v", rmErr) | ||
| } else { | ||
| updateExtensionCheckLog.Printf("Pre-removed executable to avoid ETXTBSY on Linux: %s", exe) | ||
| } | ||
| } | ||
| } |
|
@copilot review comments |
…e failure Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com>
|
@copilot review the risk of bricking the extension and make sure that the copy/swap is done when the new binary is ready and minize potential failure that would break the installation. |
…fails Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com>
Reworked in f37859c. The swap now only happens after the upgrade has already been attempted:
The first attempt's error output is buffered on Linux and suppressed when the retry succeeds, so users see a clean experience. The binary is never touched on systems where the upgrade works first time. |
gh aw upgradefails on Linux/WSL becausegh extension upgradecannot overwrite thegh-awbinary while it is the currently-executing process — the kernel returnsETXTBSYwhen any process attempts toopen()a memory-mapped executable for writing.Change
The upgrade now uses a try-first, rename-retry strategy to minimize the risk of leaving the installation in a broken state:
gh extension upgradeis run directly, without touching the filesystem. On most systems — and on Linux when there is no in-use binary conflict — this succeeds with zero file manipulation..bakbackup path, freeing the original path forghto write the newly-downloaded binary. The inode remains alive for the running process.gh-awinstallation.os.Executable()returns a"(deleted)"-suffixed path on Linux.upgradeExtensionIfOutdatedreturns the pre-rename install path, passed directly torelaunchWithSameArgsvia a newexeOverrideparameter, ensuring the freshly-installed binary is exec'd correctly.On Linux, the first attempt's output is buffered and suppressed when the rename+retry succeeds, so users do not see a confusing error followed by success.
ETXTBSY, but with an informative error fromgh).firstAttemptWriter,renamePathForUpgrade,restoreExecutableBackup,cleanupExecutableBackup) are extracted and covered by unit tests.💬 Send tasks to Copilot coding agent from Slack and Teams to turn conversations into code. Copilot posts an update in your thread when it's finished.