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 <noreply@anthropic.com>
This commit is contained in:
Stefan Haller 2026-07-20 14:50:00 +02:00
parent 400faea60c
commit f000ce9f1c
3 changed files with 15 additions and 6 deletions

View file

@ -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()
}

View file

@ -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

View file

@ -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)
}