From e9fa1bb8630f9c30fa8df121b33d9bfdff37215d Mon Sep 17 00:00:00 2001 From: omartelo Date: Thu, 16 Jul 2026 16:40:58 -0300 Subject: [PATCH] fix: stop Windows console-window flood from child processes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit lich's Windows binary is built for the GUI subsystem (-H=windowsgui) and has no console of its own. Any console tool it spawns via exec.Command — git, gh, claude — therefore got a fresh console window allocated by the OS per call. With git status polled every ~3s per session card and the gh PR lookup firing on every window focus, the desktop filled with console windows and the machine became unusable. Add internal/winexec.Hide, which sets CREATE_NO_WINDOW on Windows and is a no-op everywhere else, and route every Windows-reachable console spawn through it: git/gh in internal/project (via a package-local command() helper) and the claude CLI in internal/claudeplugin. --- CHANGELOG.md | 10 +++++++++ internal/claudeplugin/claudeplugin.go | 6 +++++- internal/project/difftext.go | 4 ++-- internal/project/project.go | 21 +++++++++++++++---- internal/project/worktree.go | 5 ++--- internal/winexec/winexec_other.go | 8 ++++++++ internal/winexec/winexec_other_test.go | 18 ++++++++++++++++ internal/winexec/winexec_windows.go | 25 +++++++++++++++++++++++ internal/winexec/winexec_windows_test.go | 26 ++++++++++++++++++++++++ 9 files changed, 113 insertions(+), 10 deletions(-) create mode 100644 internal/winexec/winexec_other.go create mode 100644 internal/winexec/winexec_other_test.go create mode 100644 internal/winexec/winexec_windows.go create mode 100644 internal/winexec/winexec_windows_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index b164dc9eb..f5cc0ddeb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed + +- Windows no longer floods the desktop with console windows. lich's Windows + binary runs in the GUI subsystem (no console of its own), so every console + tool it shells out to — `git`, `gh`, `claude` — spawned a fresh console window + per call; with git status polled every few seconds per session and the PR + lookup firing on every window focus, the screen filled with windows and the + machine became unusable. Child console processes are now created with + `CREATE_NO_WINDOW` (`internal/winexec`), a no-op on every other OS. + ## [0.6.0] - 2026-07-16 ### Added diff --git a/internal/claudeplugin/claudeplugin.go b/internal/claudeplugin/claudeplugin.go index fb7bb0b65..5e31c724b 100644 --- a/internal/claudeplugin/claudeplugin.go +++ b/internal/claudeplugin/claudeplugin.go @@ -17,6 +17,8 @@ import ( "strconv" "strings" "time" + + "github.com/omartelo/lich/internal/winexec" ) const ( @@ -116,7 +118,9 @@ func (s *Service) runClaude(args ...string) error { } ctx, cancel := context.WithTimeout(context.Background(), cmdTimeout) defer cancel() - out, err := exec.CommandContext(ctx, bin, args...).CombinedOutput() + cmd := exec.CommandContext(ctx, bin, args...) + winexec.Hide(cmd) + out, err := cmd.CombinedOutput() if err != nil { return fmt.Errorf("claude %s: %w: %s", strings.Join(args, " "), err, strings.TrimSpace(string(out))) } diff --git a/internal/project/difftext.go b/internal/project/difftext.go index 8ac53463e..3a5ffbf3e 100644 --- a/internal/project/difftext.go +++ b/internal/project/difftext.go @@ -41,7 +41,7 @@ func (s *Service) DiffText(path string) (string, error) { // untrackedFiles lists paths unknown to git, relative to the work tree root. // Errors yield an empty list — the tracked diff is still worth returning. func untrackedFiles(path string) []string { - out, err := exec.Command("git", "-C", path, "ls-files", "--others", "--exclude-standard", "-z").Output() + out, err := command("git", "-C", path, "ls-files", "--others", "--exclude-standard", "-z").Output() if err != nil { return nil } @@ -61,7 +61,7 @@ func untrackedDiff(dir, rel string) string { if info, err := os.Stat(filepath.Join(dir, rel)); err != nil || !info.Mode().IsRegular() || info.Size() > maxUntrackedDiffSize { return "" } - cmd := exec.Command("git", "-C", dir, "diff", "--no-index", "--", os.DevNull, rel) + cmd := command("git", "-C", dir, "diff", "--no-index", "--", os.DevNull, rel) out, err := cmd.Output() var exitErr *exec.ExitError if err != nil && (!errors.As(err, &exitErr) || exitErr.ExitCode() != 1) { diff --git a/internal/project/project.go b/internal/project/project.go index eb05a132c..6bf2b1327 100644 --- a/internal/project/project.go +++ b/internal/project/project.go @@ -17,8 +17,20 @@ import ( "strconv" "strings" "time" + + "github.com/omartelo/lich/internal/winexec" ) +// command builds an exec.Cmd for a child console tool (git, gh) with its +// console window suppressed on Windows — lich's GUI-subsystem binary would +// otherwise pop one per spawn (winexec.Hide). Used across this package instead +// of exec.Command so no polling call site can forget it. +func command(name string, args ...string) *exec.Cmd { + cmd := exec.Command(name, args...) + winexec.Hide(cmd) + return cmd +} + // Project identifies an opened project directory. type Project struct { ID string `json:"id"` @@ -66,7 +78,7 @@ func newProject(path string) *Project { // the current state on call, so a checkout made after opening is not reflected // until the branch is resolved again. func (s *Service) Branch(path string) string { - out, err := exec.Command("git", "-C", path, "symbolic-ref", "--short", "HEAD").Output() + out, err := command("git", "-C", path, "symbolic-ref", "--short", "HEAD").Output() if err != nil { return "" } @@ -95,6 +107,7 @@ func (s *Service) PullRequest(path string) *PullRequest { defer cancel() cmd := exec.CommandContext(ctx, "gh", "pr", "view", "--json", "number,url,state") cmd.Dir = path + winexec.Hide(cmd) out, err := cmd.Output() if err != nil { return nil @@ -139,10 +152,10 @@ type DiffStats struct { // Branch's contract. func (s *Service) Diff(path string) DiffStats { var stats DiffStats - if out, err := exec.Command("git", "-C", path, "status", "--porcelain").Output(); err == nil { + if out, err := command("git", "-C", path, "status", "--porcelain").Output(); err == nil { stats.Files = countLines(out) } - out, err := exec.Command("git", "-C", path, "diff", "--numstat", "HEAD").Output() + out, err := command("git", "-C", path, "diff", "--numstat", "HEAD").Output() if err != nil { return stats } @@ -159,7 +172,7 @@ func (s *Service) Diff(path string) DiffStats { } // Untracked files are invisible to `git diff`; count their lines as // additions, the way Warp and forge diff views present a fresh file. - if out, err := exec.Command("git", "-C", path, "ls-files", "--others", "--exclude-standard", "-z").Output(); err == nil { + if out, err := command("git", "-C", path, "ls-files", "--others", "--exclude-standard", "-z").Output(); err == nil { for _, rel := range strings.Split(string(out), "\x00") { if rel != "" { stats.Added += countFileLines(filepath.Join(path, rel)) diff --git a/internal/project/worktree.go b/internal/project/worktree.go index e05aa4247..556362e8a 100644 --- a/internal/project/worktree.go +++ b/internal/project/worktree.go @@ -4,7 +4,6 @@ import ( "bytes" "fmt" "os" - "os/exec" "path/filepath" "strings" ) @@ -28,7 +27,7 @@ type Branches struct { // deliberately swallow errors for polling, failures here carry git's stderr in // the message so the frontend can show it verbatim. func runGit(dir string, args ...string) (string, error) { - cmd := exec.Command("git", append([]string{"-C", dir}, args...)...) + cmd := command("git", append([]string{"-C", dir}, args...)...) var stderr bytes.Buffer cmd.Stderr = &stderr out, err := cmd.Output() @@ -198,7 +197,7 @@ func (s *Service) WorktreeDirty(wtPath string) (bool, error) { // branchExists reports whether refs/heads/ exists in the repository. func branchExists(projectPath, name string) bool { - err := exec.Command("git", "-C", projectPath, "show-ref", "--verify", "--quiet", "refs/heads/"+name).Run() + err := command("git", "-C", projectPath, "show-ref", "--verify", "--quiet", "refs/heads/"+name).Run() return err == nil } diff --git a/internal/winexec/winexec_other.go b/internal/winexec/winexec_other.go new file mode 100644 index 000000000..ca8ec560b --- /dev/null +++ b/internal/winexec/winexec_other.go @@ -0,0 +1,8 @@ +//go:build !windows + +package winexec + +import "os/exec" + +// Hide is a no-op off Windows, where a console child has no window to hide. +func Hide(cmd *exec.Cmd) {} diff --git a/internal/winexec/winexec_other_test.go b/internal/winexec/winexec_other_test.go new file mode 100644 index 000000000..cd5badb1c --- /dev/null +++ b/internal/winexec/winexec_other_test.go @@ -0,0 +1,18 @@ +//go:build !windows + +package winexec + +import ( + "os/exec" + "testing" +) + +// Off Windows there is no console window to hide, so Hide must leave the +// command untouched — anything else would be a portability surprise. +func TestHideIsNoOp(t *testing.T) { + cmd := exec.Command("git", "--version") + Hide(cmd) + if cmd.SysProcAttr != nil { + t.Fatalf("Hide set SysProcAttr off Windows: %#v", cmd.SysProcAttr) + } +} diff --git a/internal/winexec/winexec_windows.go b/internal/winexec/winexec_windows.go new file mode 100644 index 000000000..3920d95be --- /dev/null +++ b/internal/winexec/winexec_windows.go @@ -0,0 +1,25 @@ +//go:build windows + +// Package winexec suppresses the console window Windows would otherwise +// allocate for every console tool lich shells out to (git, gh, claude). lich's +// Windows binary is built for the GUI subsystem (-H=windowsgui) and so has no +// console of its own; without CREATE_NO_WINDOW, each such child pops a fresh +// console window on the user's desktop — and the git-status poll spawning one +// every few seconds per session buries the screen. Off Windows this is a no-op. +package winexec + +import ( + "os/exec" + "syscall" + + "golang.org/x/sys/windows" +) + +// Hide sets the creation flag that keeps cmd from allocating a console window. +func Hide(cmd *exec.Cmd) { + if cmd.SysProcAttr == nil { + cmd.SysProcAttr = &syscall.SysProcAttr{} + } + cmd.SysProcAttr.HideWindow = true + cmd.SysProcAttr.CreationFlags |= windows.CREATE_NO_WINDOW +} diff --git a/internal/winexec/winexec_windows_test.go b/internal/winexec/winexec_windows_test.go new file mode 100644 index 000000000..0d9ffe475 --- /dev/null +++ b/internal/winexec/winexec_windows_test.go @@ -0,0 +1,26 @@ +//go:build windows + +package winexec + +import ( + "os/exec" + "testing" + + "golang.org/x/sys/windows" +) + +// The whole point on Windows: Hide must set CREATE_NO_WINDOW so lich's +// windowless GUI binary does not spawn a console window per child. +func TestHideSetsCreateNoWindow(t *testing.T) { + cmd := exec.Command("git", "--version") + Hide(cmd) + if cmd.SysProcAttr == nil { + t.Fatal("Hide left SysProcAttr nil on Windows") + } + if cmd.SysProcAttr.CreationFlags&windows.CREATE_NO_WINDOW == 0 { + t.Fatalf("CREATE_NO_WINDOW not set: flags=%#x", cmd.SysProcAttr.CreationFlags) + } + if !cmd.SysProcAttr.HideWindow { + t.Fatal("HideWindow not set") + } +}