Skip to content

Commit 0283bd7

Browse files
fix(cli): self uninstall finishes on Windows, keeps its record, and names a kept file by its reason (#1036)
* docs(plans): 1003, self uninstall finishes on Windows and tells the truth about what it left * test(cli): a refused file is not modified, the manifest is kept while a file remains, and uninstall runs from the installed copy, failing (#1003) * fix(cli): self uninstall names a kept file by its reason, keeps its record, and on Windows deletes the running image after it exits (#1003)
1 parent 71dc125 commit 0283bd7

6 files changed

Lines changed: 648 additions & 34 deletions

File tree

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
// SPDX-License-Identifier: Apache-2.0
2+
// Copyright Noble Factor. All rights reserved.
3+
4+
//go:build !windows
5+
6+
package cli
7+
8+
import "github.com/NobleFactor/devlore-cli/pkg/fsroot"
9+
10+
// deferRunningImageDeletion never defers on a non-Windows platform: unlinking a running binary succeeds here -- the
11+
// directory entry goes at once, the inode lingers until the process exits -- so the running image is removed in place
12+
// like any other recorded file. It always returns false, so the caller removes it inline (#1003).
13+
//
14+
// Parameters:
15+
// - `prefixRoot`: the installation prefix (unused here).
16+
// - `running`: the running image's recorded entry (unused here).
17+
//
18+
// Returns:
19+
// - `bool`: always false.
20+
func deferRunningImageDeletion(_ fsroot.Dir, _ manifestEntry) bool {
21+
return false
22+
}
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
// SPDX-License-Identifier: Apache-2.0
2+
// Copyright Noble Factor. All rights reserved.
3+
4+
//go:build windows
5+
6+
package cli
7+
8+
import (
9+
"context"
10+
"fmt"
11+
"os/exec"
12+
"syscall"
13+
14+
"github.com/NobleFactor/devlore-cli/pkg/fsroot"
15+
)
16+
17+
// Windows process-creation flags (golang.org/x/sys/windows mirrors these; named here to keep this file's imports to
18+
// the standard library and fsroot).
19+
const (
20+
createNoWindow = 0x08000000 // CREATE_NO_WINDOW: the child gets no console window.
21+
detachedProcess = 0x00000008 // DETACHED_PROCESS: the child has no console and outlives this process.
22+
23+
// deleteRetries bounds the wait: ping -n 2 pauses about a second between the loop's tries, so this is roughly a
24+
// two-minute ceiling on a process that never exits. A normal shutdown releases the image in moments.
25+
deleteRetries = 120
26+
)
27+
28+
// deferRunningImageDeletion arranges for the running image to be deleted once this process exits, and reports whether
29+
// it took ownership of that deletion.
30+
//
31+
// Windows refuses to delete a mapped image, so the running .exe cannot delete itself in place. The deletion is handed
32+
// to cmd.exe -- always present, copied nowhere, left nowhere. Its loop retries the delete until this process releases
33+
// the image and the delete succeeds, then stops; a cap bounds a process that never exits. ping, not timeout, is the
34+
// pause: timeout fails without a console, which is exactly this detached case.
35+
//
36+
// It returns true when cmd.exe was started, so the caller leaves the running image out of its own in-place removal.
37+
// It returns false only when cmd.exe could not be started; the caller then tries the delete in place, which fails and
38+
// is reported and kept recorded, and a later run -- no longer the running image -- removes the file.
39+
//
40+
// Parameters:
41+
// - `prefixRoot`: the installation prefix the entry is relative to.
42+
// - `running`: the running image's recorded entry.
43+
//
44+
// Returns:
45+
// - `bool`: whether cmd.exe took ownership of the deletion.
46+
func deferRunningImageDeletion(prefixRoot fsroot.Dir, running manifestEntry) bool {
47+
48+
// The path is quoted here, with literal quote characters, rather than with %q in the format: %q applies Go's
49+
// escaping, which would double the backslashes in a Windows path.
50+
quoted := `"` + prefixRoot.NewPath(running.Path).Abs() + `"`
51+
52+
// The outer quote pair wraps the whole command; cmd /c strips it (the command holds special characters, so the
53+
// inner quotes around the path are preserved). %%i is a literal %i on the command line -- doubling is fmt's, not
54+
// cmd's.
55+
commandLine := fmt.Sprintf(
56+
`cmd.exe /c "for /l %%i in (1,1,%d) do (del /f /q %s 2>nul & if not exist %s exit & ping -n 2 127.0.0.1 >nul)"`,
57+
deleteRetries, quoted, quoted,
58+
)
59+
60+
// context.Background(): the child is detached and must outlive this process, so its lifetime is not tied to a
61+
// context that this process's exit would cancel.
62+
command := exec.CommandContext(context.Background(), "cmd.exe") //nolint:gosec // G204: cmd.exe is the system shell; the command line is built here, not from input
63+
command.SysProcAttr = &syscall.SysProcAttr{
64+
HideWindow: true,
65+
CreationFlags: createNoWindow | detachedProcess,
66+
CmdLine: commandLine,
67+
}
68+
69+
if err := command.Start(); err != nil {
70+
Warn("Could not schedule the running program's removal: %v", err)
71+
return false
72+
}
73+
74+
// The child is detached and outlives this process; its handles are released, not waited on.
75+
_ = command.Process.Release() //nolint:errcheck // nothing to do if the handle cannot be released
76+
77+
return true
78+
}

0 commit comments

Comments
 (0)