Skip to content

Commit 77d9f4e

Browse files
joohwcursoragent
andcommitted
fix(windows): stop proxy before replacing locked clovapi.exe
Allow npm install and clovapi update to replace the user-managed CLI on Windows by stopping the proxy first and falling back to a deferred swap when the binary is still locked. Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent fb54134 commit 77d9f4e

10 files changed

Lines changed: 501 additions & 44 deletions

File tree

core/internal/buildinfo/buildinfo.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import "strings"
44

55
// Set at link time via -ldflags (see .goreleaser.yaml).
66
var (
7-
Version = "dev0.1.36"
7+
Version = "dev0.1.37"
88
Commit = "none"
99
Date = "unknown"
1010
)

core/internal/selfupdate/selfupdate.go

Lines changed: 1 addition & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -146,7 +146,7 @@ func Update(ctx context.Context, execPath string, opts Options) (Result, error)
146146
return res, err
147147
}
148148
defer unlock()
149-
if err := installBinary(binary, target); err != nil {
149+
if err := installBinary(binary, target, execPath); err != nil {
150150
return res, err
151151
}
152152
if err := writeVersionMeta(latest); err != nil {
@@ -377,40 +377,3 @@ func extractFromTarGz(data []byte) ([]byte, error) {
377377
return nil, fmt.Errorf("clovapi not found in archive")
378378
}
379379

380-
func installBinary(data []byte, targetPath string) error {
381-
targetPath = strings.TrimSpace(targetPath)
382-
if targetPath == "" {
383-
return fmt.Errorf("target path is empty")
384-
}
385-
if err := os.MkdirAll(filepath.Dir(targetPath), 0o700); err != nil {
386-
return err
387-
}
388-
tmp, err := os.CreateTemp(filepath.Dir(targetPath), ".clovapi-update-*")
389-
if err != nil {
390-
return err
391-
}
392-
tmpName := tmp.Name()
393-
cleanup := func() { _ = os.Remove(tmpName) }
394-
if _, err := tmp.Write(data); err != nil {
395-
tmp.Close()
396-
cleanup()
397-
return err
398-
}
399-
if err := tmp.Chmod(0o755); err != nil {
400-
tmp.Close()
401-
cleanup()
402-
return err
403-
}
404-
if err := tmp.Close(); err != nil {
405-
cleanup()
406-
return err
407-
}
408-
if err := os.Rename(tmpName, targetPath); err != nil {
409-
_ = os.Remove(targetPath)
410-
if err2 := os.Rename(tmpName, targetPath); err2 != nil {
411-
cleanup()
412-
return err
413-
}
414-
}
415-
return nil
416-
}
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
//go:build !windows
2+
3+
package selfupdate
4+
5+
import (
6+
"fmt"
7+
"os"
8+
"path/filepath"
9+
"strings"
10+
)
11+
12+
func installBinary(data []byte, targetPath, execPath string) error {
13+
_ = execPath
14+
targetPath = strings.TrimSpace(targetPath)
15+
if targetPath == "" {
16+
return fmt.Errorf("target path is empty")
17+
}
18+
if err := os.MkdirAll(filepath.Dir(targetPath), 0o700); err != nil {
19+
return err
20+
}
21+
tmp, err := os.CreateTemp(filepath.Dir(targetPath), ".clovapi-update-*")
22+
if err != nil {
23+
return err
24+
}
25+
tmpName := tmp.Name()
26+
cleanup := func() { _ = os.Remove(tmpName) }
27+
if _, err := tmp.Write(data); err != nil {
28+
tmp.Close()
29+
cleanup()
30+
return err
31+
}
32+
if err := tmp.Chmod(0o755); err != nil {
33+
tmp.Close()
34+
cleanup()
35+
return err
36+
}
37+
if err := tmp.Close(); err != nil {
38+
cleanup()
39+
return err
40+
}
41+
if err := os.Rename(tmpName, targetPath); err != nil {
42+
_ = os.Remove(targetPath)
43+
if err2 := os.Rename(tmpName, targetPath); err2 != nil {
44+
cleanup()
45+
return err
46+
}
47+
}
48+
return nil
49+
}
Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,174 @@
1+
//go:build windows
2+
3+
package selfupdate
4+
5+
import (
6+
"fmt"
7+
"os"
8+
"os/exec"
9+
"path/filepath"
10+
"strings"
11+
"syscall"
12+
"time"
13+
)
14+
15+
func installBinary(data []byte, targetPath, execPath string) error {
16+
targetPath = strings.TrimSpace(targetPath)
17+
if targetPath == "" {
18+
return fmt.Errorf("target path is empty")
19+
}
20+
if err := os.MkdirAll(filepath.Dir(targetPath), 0o700); err != nil {
21+
return err
22+
}
23+
24+
tmp, err := os.CreateTemp(filepath.Dir(targetPath), ".clovapi-update-*")
25+
if err != nil {
26+
return err
27+
}
28+
tmpName := tmp.Name()
29+
cleanupTmp := func() { _ = os.Remove(tmpName) }
30+
if _, err := tmp.Write(data); err != nil {
31+
tmp.Close()
32+
cleanupTmp()
33+
return err
34+
}
35+
if err := tmp.Close(); err != nil {
36+
cleanupTmp()
37+
return err
38+
}
39+
40+
stopper := strings.TrimSpace(execPath)
41+
if stopper == "" {
42+
stopper = targetPath
43+
}
44+
stopProxyBeforeInstall(stopper)
45+
46+
if sameInstalledBinary(targetPath, execPath) {
47+
return installBinaryDeferredSelfUpdate(tmpName, targetPath, stopper)
48+
}
49+
50+
if err := replaceWindowsBinary(tmpName, targetPath, stopper); err == nil {
51+
return nil
52+
}
53+
cleanupTmp()
54+
return installBinaryDeferredReplace(data, targetPath, stopper)
55+
}
56+
57+
func stopProxyBeforeInstall(cliPath string) {
58+
cliPath = strings.TrimSpace(cliPath)
59+
if cliPath == "" {
60+
return
61+
}
62+
if _, err := os.Stat(cliPath); err != nil {
63+
return
64+
}
65+
cmd := exec.Command(cliPath, "proxy", "stop")
66+
cmd.Stdout = nil
67+
cmd.Stderr = nil
68+
_ = cmd.Run()
69+
time.Sleep(400 * time.Millisecond)
70+
}
71+
72+
func replaceWindowsBinary(sourcePath, targetPath, cliPath string) error {
73+
for attempt := 0; attempt < 8; attempt++ {
74+
if _, err := os.Stat(targetPath); err == nil {
75+
_ = os.Remove(targetPath)
76+
if _, err := os.Stat(targetPath); err == nil {
77+
oldPath := targetPath + ".old"
78+
_ = os.Remove(oldPath)
79+
_ = os.Rename(targetPath, oldPath)
80+
}
81+
}
82+
if err := os.Rename(sourcePath, targetPath); err == nil {
83+
_ = os.Remove(targetPath + ".old")
84+
return nil
85+
}
86+
stopProxyBeforeInstall(cliPath)
87+
time.Sleep(time.Duration(300*(attempt+1)) * time.Millisecond)
88+
}
89+
return fmt.Errorf("replace locked binary %s", targetPath)
90+
}
91+
92+
func installBinaryDeferredReplace(data []byte, targetPath, cliPath string) error {
93+
pendingPath := targetPath + ".new"
94+
if err := os.WriteFile(pendingPath, data, 0o755); err != nil {
95+
return err
96+
}
97+
if runDeferredWindowsReplace(targetPath, pendingPath, cliPath, 0) {
98+
return nil
99+
}
100+
_ = os.Remove(pendingPath)
101+
return fmt.Errorf("EPERM: operation not permitted, replace %q; stop the clovapi proxy and retry", targetPath)
102+
}
103+
104+
func installBinaryDeferredSelfUpdate(tmpPath, targetPath, cliPath string) error {
105+
pendingPath := targetPath + ".new"
106+
_ = os.Remove(pendingPath)
107+
if err := os.Rename(tmpPath, pendingPath); err != nil {
108+
_ = os.Remove(tmpPath)
109+
return err
110+
}
111+
pid := os.Getpid()
112+
if !runDeferredWindowsReplaceDetached(targetPath, pendingPath, cliPath, pid) {
113+
_ = os.Remove(pendingPath)
114+
return fmt.Errorf("failed to schedule self-update for %q", targetPath)
115+
}
116+
return nil
117+
}
118+
119+
func runDeferredWindowsReplace(targetPath, pendingPath, cliPath string, waitPID int) bool {
120+
script := deferredReplaceScript(targetPath, pendingPath, cliPath, waitPID)
121+
cmd := exec.Command("powershell.exe", "-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", script)
122+
cmd.Stdout = nil
123+
cmd.Stderr = nil
124+
if err := cmd.Run(); err != nil {
125+
return false
126+
}
127+
if _, err := os.Stat(targetPath); err != nil {
128+
return false
129+
}
130+
if _, err := os.Stat(pendingPath); err == nil {
131+
return false
132+
}
133+
return true
134+
}
135+
136+
func runDeferredWindowsReplaceDetached(targetPath, pendingPath, cliPath string, waitPID int) bool {
137+
script := deferredReplaceScript(targetPath, pendingPath, cliPath, waitPID)
138+
cmd := exec.Command("powershell.exe", "-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", script)
139+
cmd.Stdout = nil
140+
cmd.Stderr = nil
141+
cmd.Stdin = nil
142+
cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true}
143+
return cmd.Start() == nil
144+
}
145+
146+
func deferredReplaceScript(targetPath, pendingPath, cliPath string, waitPID int) string {
147+
waitBlock := ""
148+
if waitPID > 0 {
149+
waitBlock = fmt.Sprintf(
150+
"Wait-Process -Id %d -ErrorAction SilentlyContinue\nStart-Sleep -Milliseconds 300\n",
151+
waitPID,
152+
)
153+
}
154+
return strings.Join([]string{
155+
"$ErrorActionPreference = 'Continue'",
156+
fmt.Sprintf("$target = %q", targetPath),
157+
fmt.Sprintf("$pending = %q", pendingPath),
158+
fmt.Sprintf("$cli = %q", cliPath),
159+
"for ($i = 0; $i -lt 40; $i++) {",
160+
" try {",
161+
waitBlock,
162+
" if (Test-Path $cli) { & $cli proxy stop 2>$null | Out-Null }",
163+
" Start-Sleep -Milliseconds 300",
164+
" if (Test-Path $target) { Remove-Item -LiteralPath $target -Force -ErrorAction Stop }",
165+
" Move-Item -LiteralPath $pending -Destination $target -Force -ErrorAction Stop",
166+
" Remove-Item -LiteralPath ($target + '.old') -Force -ErrorAction SilentlyContinue",
167+
" exit 0",
168+
" } catch {",
169+
" Start-Sleep -Milliseconds 500",
170+
" }",
171+
"}",
172+
"exit 1",
173+
}, "\n")
174+
}

0 commit comments

Comments
 (0)