From 5209294a56e752beaf9b9e175c9f795bb5df869f Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Mon, 20 Jul 2026 14:32:33 +0200 Subject: [PATCH 1/5] Extract helper for starting a command with piped output The fallback path in newPtyTask (taken when StartPty fails) needs the same start-the-command-with-a-pipe logic that newCmdTask uses, so pull it out into a helper that both can share. No behavior change. Co-Authored-By: Claude Fable 5 --- pkg/gui/tasks_adapter.go | 35 ++++++++++++++++++++++------------- 1 file changed, 22 insertions(+), 13 deletions(-) diff --git a/pkg/gui/tasks_adapter.go b/pkg/gui/tasks_adapter.go index 27aacf58b..3eb446a90 100644 --- a/pkg/gui/tasks_adapter.go +++ b/pkg/gui/tasks_adapter.go @@ -7,6 +7,7 @@ import ( "github.com/jesseduffield/lazygit/pkg/gocui" "github.com/jesseduffield/lazygit/pkg/tasks" + "github.com/sirupsen/logrus" ) func (gui *Gui) newCmdTask(view *gocui.View, cmd *exec.Cmd, prefix string) error { @@ -29,19 +30,9 @@ func (gui *Gui) newCmdTask(view *gocui.View, cmd *exec.Cmd, prefix string) error start := func() (tasks.Cmd, io.Reader) { view.SetContentWidth(contentWidth) - var err error - r, err = cmd.StdoutPipe() - if err != nil { - gui.c.Log.Error(err) - r = nil - } - cmd.Stderr = cmd.Stdout - - if err := cmd.Start(); err != nil { - gui.c.Log.Error(err) - } - - return tasks.ExecCmd{Cmd: cmd}, r + execCmd, pipe := startCmdWithPipe(cmd, gui.c.Log) + r = pipe + return execCmd, pipe } onClose := func() { @@ -59,6 +50,24 @@ func (gui *Gui) newCmdTask(view *gocui.View, cmd *exec.Cmd, prefix string) error return nil } +// startCmdWithPipe starts cmd with its stdout and stderr going to a single +// pipe, and returns the command along with the pipe's read end, in the shape +// that NewCmdTask expects from its start func. +func startCmdWithPipe(cmd *exec.Cmd, log *logrus.Entry) (tasks.Cmd, io.ReadCloser) { + r, err := cmd.StdoutPipe() + if err != nil { + log.Error(err) + r = nil + } + cmd.Stderr = cmd.Stdout + + if err := cmd.Start(); err != nil { + log.Error(err) + } + + return tasks.ExecCmd{Cmd: cmd}, r +} + func (gui *Gui) newStringTask(view *gocui.View, str string) error { // using str so that if rendering the exact same thing we don't reset the origin return gui.newStringTaskWithKey(view, str, str) From 400faea60ceffc183d0fe43fb91fd2fd2d65eceb Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Mon, 20 Jul 2026 14:38:13 +0200 Subject: [PATCH 2/5] Add test showing startCmdWithPipe returns a nil reader on pipe failure NewCmdTask feeds the reader returned by its start func straight into a bufio.Scanner, whose Scan panics on a nil reader with a nil pointer dereference. startCmdWithPipe returns exactly that when the pipe cannot be created. Co-Authored-By: Claude Fable 5 --- pkg/gui/tasks_adapter_test.go | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) create mode 100644 pkg/gui/tasks_adapter_test.go diff --git a/pkg/gui/tasks_adapter_test.go b/pkg/gui/tasks_adapter_test.go new file mode 100644 index 000000000..7821f835a --- /dev/null +++ b/pkg/gui/tasks_adapter_test.go @@ -0,0 +1,27 @@ +package gui + +import ( + "bytes" + "os/exec" + "testing" + + "github.com/jesseduffield/lazygit/pkg/utils" + "github.com/stretchr/testify/assert" +) + +func TestStartCmdWithPipeWhenPipeCannotBeCreated(t *testing.T) { + cmd := exec.Command("non-existent-command") + // Assigning stdout up front makes cmd.StdoutPipe fail. This happens in + // practice on the Unix pty fallback path: a failed pty start can leave + // the tty assigned to the command's stdout. + cmd.Stdout = &bytes.Buffer{} + + _, r := startCmdWithPipe(cmd, utils.NewDummyLog()) + + // NewCmdTask's scanner panics on a nil reader, so startCmdWithPipe must + // not return one even when it can't create the pipe. + /* EXPECTED: + assert.NotNil(t, r) + ACTUAL: */ + assert.Nil(t, r) +} From f000ce9f1ce6a55cb23d8e377775a4a2ded9b852 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Mon, 20 Jul 2026 14:50:00 +0200 Subject: [PATCH 3/5] Never hand NewCmdTask a nil reader when a command fails to start NewCmdTask feeds the reader returned by its start func into a bufio.Scanner, and Scanner.Scan panics with a nil pointer dereference when that reader is nil. Two start funcs could produce one: - newPtyTask's fallback for a failed StartPty returned a literal nil reader, alongside an ExecCmd that was never started, so the intended "fall back to a plain cmd task" never worked. This crashed lazygit on Windows when using a custom pager with the main view zero-sized, e.g. after pressing + twice to enter full-screen mode with a side panel focused: ConPTY rejects zero dimensions, making StartPty fail. - startCmdWithPipe returned nil when the pipe couldn't be created, which the Unix pty fallback path can trigger, since a failed pty start can leave the tty assigned to the command's stdout. Make startCmdWithPipe never return a nil reader: when the pipe can't be created, don't start the command at all and return an empty reader so the task shuts down cleanly with the error in the log. Then route newPtyTask's fallback through it, so a StartPty failure degrades to running the command without a pty: the pager is lost, but the command's output still renders. Co-Authored-By: Claude Fable 5 --- pkg/gui/pty.go | 11 ++++++++++- pkg/gui/tasks_adapter.go | 7 +++++-- pkg/gui/tasks_adapter_test.go | 3 --- 3 files changed, 15 insertions(+), 6 deletions(-) diff --git a/pkg/gui/pty.go b/pkg/gui/pty.go index dbf968048..e10ea8ec7 100644 --- a/pkg/gui/pty.go +++ b/pkg/gui/pty.go @@ -100,6 +100,7 @@ func (gui *Gui) newPtyTask(view *gocui.View, cmd *exec.Cmd, prefix string) error cols, rows := gui.desiredPtySize(view) var p oscommands.Pty + var fallbackPipe io.ReadCloser start := func() (tasks.Cmd, io.Reader) { // The pty (and pager) wrap to this width; apply it here, on the // task's goroutine once the previous task has stopped, so it doesn't @@ -109,7 +110,11 @@ func (gui *Gui) newPtyTask(view *gocui.View, cmd *exec.Cmd, prefix string) error sp, err := oscommands.StartPty(cmd, cols, rows) if err != nil { gui.c.Log.Error(err) - return tasks.ExecCmd{Cmd: cmd}, nil + // Fall back to running the command without a pty: the pager is + // lost, but the command's output still renders. + execCmd, pipe := startCmdWithPipe(cmd, gui.c.Log) + fallbackPipe = pipe + return execCmd, pipe } p = sp.Pty @@ -125,6 +130,10 @@ func (gui *Gui) newPtyTask(view *gocui.View, cmd *exec.Cmd, prefix string) error if p != nil { p.Close() } + if fallbackPipe != nil { + fallbackPipe.Close() + fallbackPipe = nil + } delete(gui.viewPtmxMap, view.Name()) gui.Mutexes.PtyMutex.Unlock() } diff --git a/pkg/gui/tasks_adapter.go b/pkg/gui/tasks_adapter.go index 3eb446a90..3dce93874 100644 --- a/pkg/gui/tasks_adapter.go +++ b/pkg/gui/tasks_adapter.go @@ -52,12 +52,15 @@ func (gui *Gui) newCmdTask(view *gocui.View, cmd *exec.Cmd, prefix string) error // startCmdWithPipe starts cmd with its stdout and stderr going to a single // pipe, and returns the command along with the pipe's read end, in the shape -// that NewCmdTask expects from its start func. +// that NewCmdTask expects from its start func. It never returns a nil reader, +// because NewCmdTask's scanner panics on one: when the pipe can't be created +// the command isn't started at all, and an empty reader is returned so that +// the task shuts down cleanly with the error in the log. func startCmdWithPipe(cmd *exec.Cmd, log *logrus.Entry) (tasks.Cmd, io.ReadCloser) { r, err := cmd.StdoutPipe() if err != nil { log.Error(err) - r = nil + return tasks.ExecCmd{Cmd: cmd}, io.NopCloser(strings.NewReader("")) } cmd.Stderr = cmd.Stdout diff --git a/pkg/gui/tasks_adapter_test.go b/pkg/gui/tasks_adapter_test.go index 7821f835a..48c1bb45f 100644 --- a/pkg/gui/tasks_adapter_test.go +++ b/pkg/gui/tasks_adapter_test.go @@ -20,8 +20,5 @@ func TestStartCmdWithPipeWhenPipeCannotBeCreated(t *testing.T) { // NewCmdTask's scanner panics on a nil reader, so startCmdWithPipe must // not return one even when it can't create the pipe. - /* EXPECTED: assert.NotNil(t, r) - ACTUAL: */ - assert.Nil(t, r) } From c217084c90b78ff9127ec5ff2c6e6f8c7bf7545a Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Mon, 20 Jul 2026 14:59:35 +0200 Subject: [PATCH 4/5] Add test showing StartPty fails on Windows when given a zero size CreatePseudoConsole rejects zero dimensions with E_INVALIDARG, so starting a pty sized after a hidden (and thus zero-sized) view fails. Co-Authored-By: Claude Fable 5 --- pkg/commands/oscommands/pty_windows_test.go | 28 +++++++++++++++++++++ 1 file changed, 28 insertions(+) create mode 100644 pkg/commands/oscommands/pty_windows_test.go diff --git a/pkg/commands/oscommands/pty_windows_test.go b/pkg/commands/oscommands/pty_windows_test.go new file mode 100644 index 000000000..d16d3b295 --- /dev/null +++ b/pkg/commands/oscommands/pty_windows_test.go @@ -0,0 +1,28 @@ +package oscommands + +import ( + "os/exec" + "testing" + + "github.com/stretchr/testify/assert" +) + +// The requested size can legitimately be zero: the pty inherits the main +// view's dimensions, and that view is zero-sized while hidden, e.g. in +// full-screen mode with a side panel focused. +func TestStartPtyWithZeroSize(t *testing.T) { + // The command deliberately produces no output: go test runs with + // redirected std handles, which CreateProcess duplicates into the child + // in place of handles to the attached pseudoconsole, so command output + // would bypass the pty and pollute the test log. + sp, err := StartPty(exec.Command("cmd", "/c", "exit 0"), 0, 0) + /* EXPECTED: + assert.NoError(t, err) + ACTUAL: */ + assert.Error(t, err) + + if err == nil { + _ = sp.Wait() + _ = sp.Pty.Close() + } +} From 02c8ba3073200f652d0152c2bdfb7da5a8af97f0 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Mon, 20 Jul 2026 15:22:30 +0200 Subject: [PATCH 5/5] Clamp ConPTY sizes to the 1x1 minimum that Windows accepts CreatePseudoConsole and ResizePseudoConsole reject zero dimensions with E_INVALIDARG, but we legitimately request them: the pty is sized after the main view, and that view is zero-sized while hidden, e.g. in full-screen mode with a side panel focused. Entering that mode while a custom pager is configured therefore made StartPty fail (degrading to unpaged output now that the fallback works), and resizing a live pty from onResize would fail layout. The Unix pty accepts zero sizes, so the clamp lives in the Windows implementation only. Co-Authored-By: Claude Fable 5 --- pkg/commands/oscommands/pty_windows.go | 13 +++++++++++-- pkg/commands/oscommands/pty_windows_test.go | 3 --- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/pkg/commands/oscommands/pty_windows.go b/pkg/commands/oscommands/pty_windows.go index 72ade5110..eaa762ed6 100644 --- a/pkg/commands/oscommands/pty_windows.go +++ b/pkg/commands/oscommands/pty_windows.go @@ -36,7 +36,16 @@ func (p *winPty) Resize(cols, rows uint16) error { // there is nothing left to resize. return nil } - return windows.ResizePseudoConsole(p.hpc, windows.Coord{X: int16(cols), Y: int16(rows)}) + return windows.ResizePseudoConsole(p.hpc, clampPtySize(cols, rows)) +} + +// clampPtySize clamps a requested pty size to the minimum that ConPTY +// accepts: CreatePseudoConsole and ResizePseudoConsole reject zero +// dimensions with E_INVALIDARG, but callers legitimately request them — the +// pty is sized after the main view, which is zero-sized while hidden, e.g. +// in full-screen mode with a side panel focused. +func clampPtySize(cols, rows uint16) windows.Coord { + return windows.Coord{X: int16(max(cols, 1)), Y: int16(max(rows, 1))} } // closeHpc closes the pseudoconsole exactly once. Safe to call from multiple @@ -140,7 +149,7 @@ func StartPty(cmd *exec.Cmd, cols, rows uint16) (sp StartedPty, err error) { // CreatePseudoConsole dupes the handles it needs internally; we release // our references to the child-side ends immediately after. var hpc windows.Handle - size := windows.Coord{X: int16(cols), Y: int16(rows)} + size := clampPtySize(cols, rows) if err = windows.CreatePseudoConsole(size, inRead, outWrite, 0, &hpc); err != nil { _ = windows.CloseHandle(inRead) _ = windows.CloseHandle(outWrite) diff --git a/pkg/commands/oscommands/pty_windows_test.go b/pkg/commands/oscommands/pty_windows_test.go index d16d3b295..0b4173561 100644 --- a/pkg/commands/oscommands/pty_windows_test.go +++ b/pkg/commands/oscommands/pty_windows_test.go @@ -16,10 +16,7 @@ func TestStartPtyWithZeroSize(t *testing.T) { // in place of handles to the attached pseudoconsole, so command output // would bypass the pty and pollute the test log. sp, err := StartPty(exec.Command("cmd", "/c", "exit 0"), 0, 0) - /* EXPECTED: assert.NoError(t, err) - ACTUAL: */ - assert.Error(t, err) if err == nil { _ = sp.Wait()