fix(pty): treat background jobs as non-interactive to avoid SIGTTOU stop (#324)

* fix(pty): treat background jobs as non-interactive to avoid SIGTTOU stop

IsInteractiveTerminal only checked that stdin/stdout are TTYs. A background
job (pmg npm run test &) still has the TTY on stdin/stdout, so pmg picked
PTY mode and called tcsetattr to enter raw mode. Changing terminal modes
from a background process group makes the kernel stop the process with
SIGTTOU, leaving the job hanging in Stopped state.

Check that the process group is the terminal's foreground process group
(tcgetpgrp == getpgrp) before treating the terminal as interactive, so
background jobs fall through to direct execution.

Fixes #322

https://claude.ai/code/session_01PBBo5CKkzg68MMGrgCTQcY

* test(pty): fail on output copy timeout to avoid racy buffer read

Reading the output buffer after a silent select timeout races with the
io.Copy goroutine still writing to it. Fail the test on timeout instead.

Also fix a grammar nit in the IsInteractiveTerminal doc comment.

https://claude.ai/code/session_01PBBo5CKkzg68MMGrgCTQcY

---------

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
Abhisek Datta
2026-06-10 09:57:08 +05:30
committed by GitHub
co-authored by Claude
parent 141894ed8f
commit 9714a6f4c2
4 changed files with 149 additions and 4 deletions
+21
View File
@@ -0,0 +1,21 @@
//go:build !windows
package pty
import (
"golang.org/x/sys/unix"
)
// isForegroundProcess reports whether this process belongs to the terminal's
// foreground process group. A background job (e.g. `pmg npm install &`) still
// has the TTY on stdin/stdout, but changing terminal modes or reading from
// the TTY in a background process group triggers SIGTTOU/SIGTTIN, which stops
// the process.
func isForegroundProcess(fd uintptr) bool {
foregroundPgrp, err := unix.IoctlGetInt(int(fd), unix.TIOCGPGRP)
if err != nil {
return false
}
return foregroundPgrp == unix.Getpgrp()
}
+9
View File
@@ -0,0 +1,9 @@
//go:build windows
package pty
// Windows has no Unix-style terminal job control, so a process is never
// stopped for touching the console from the "background".
func isForegroundProcess(_ uintptr) bool {
return true
}
+6 -4
View File
@@ -42,9 +42,11 @@ type InteractiveSession interface {
Close() error
}
// IsInteractiveTerminal returns true if stdin is a real terminal (TTY).
// Returns false in CI environments (when the "CI" env var set to "true"),
// when input or output is piped, or in non-interactive shells.
// IsInteractiveTerminal returns true if stdin is a real terminal (TTY) and
// the process can safely drive it. Returns false in CI environments (when the
// "CI" env var is set to "true"), when input or output is piped, in
// non-interactive shells, or when running as a background job (where putting
// the terminal into raw mode would stop the process with SIGTTOU).
func IsInteractiveTerminal() bool {
if ci := os.Getenv("CI"); ci != "" && strings.ToLower(ci) == "true" {
return false
@@ -58,7 +60,7 @@ func IsInteractiveTerminal() bool {
return false
}
return true
return isForegroundProcess(os.Stdin.Fd())
}
var _ InteractiveSession = &session{}
+113
View File
@@ -0,0 +1,113 @@
//go:build !windows
package pty
import (
"bytes"
"context"
"fmt"
"io"
"os"
"os/exec"
"strings"
"syscall"
"testing"
"time"
"github.com/safedep/ptyx"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
const ttyHelperEnv = "PMG_TEST_TTY_HELPER"
// Reproduces https://github.com/safedep/pmg/issues/322: a pmg process started
// as a background job (`pmg npm run test &`) still has the TTY on
// stdin/stdout, but it is not in the terminal's foreground process group.
// Treating it as interactive makes the PTY session call tcsetattr, which
// stops the process with SIGTTOU. IsInteractiveTerminal must report false in
// that situation.
func TestIsInteractiveTerminalBackgroundJob(t *testing.T) {
exe, err := os.Executable()
require.NoError(t, err)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
// The helper runs on a fresh PTY as the foreground process group, then
// moves itself to the background, checking IsInteractiveTerminal in both
// states.
sess, err := ptyx.Spawn(ctx, ptyx.SpawnOpts{
Prog: exe,
Args: []string{"-test.run", "^TestIsInteractiveTerminalTTYHelper$", "-test.v"},
Env: append(envWithoutCI(), ttyHelperEnv+"=1"),
Cols: 80,
Rows: 24,
})
require.NoError(t, err)
defer func() {
require.NoError(t, sess.Close())
}()
var output bytes.Buffer
copyDone := make(chan struct{})
go func() {
defer close(copyDone)
_, _ = io.Copy(&output, sess.PtyReader())
}()
waitErr := sess.Wait()
// The buffer must not be read until the copy goroutine is done, both to
// avoid a data race and to ensure all helper output has been captured.
select {
case <-copyDone:
case <-time.After(5 * time.Second):
t.Fatal("timed out waiting for pty output copy to finish")
}
out := output.String()
require.NoError(t, waitErr, "helper failed, output:\n%s", out)
assert.Contains(t, out, "foreground_interactive=true", "expected interactive in foreground, output:\n%s", out)
assert.Contains(t, out, "background_interactive=false", "expected non-interactive in background, output:\n%s", out)
}
// TestIsInteractiveTerminalTTYHelper is not a real test. It is re-executed by
// TestIsInteractiveTerminalBackgroundJob on a PTY where it starts as the
// foreground process group.
func TestIsInteractiveTerminalTTYHelper(t *testing.T) {
if os.Getenv(ttyHelperEnv) != "1" {
t.Skip("helper for TestIsInteractiveTerminalBackgroundJob")
}
fmt.Printf("foreground_interactive=%v\n", IsInteractiveTerminal())
// Hand the terminal's foreground process group to a child, like a shell
// does for the foreground job. This process then becomes a background job
// on the TTY, matching `pmg npm run test &`.
cmd := exec.Command("sleep", "30")
cmd.SysProcAttr = &syscall.SysProcAttr{
Setpgid: true,
Foreground: true,
Ctty: int(os.Stdin.Fd()),
}
require.NoError(t, cmd.Start())
defer func() {
_ = cmd.Process.Kill()
_, _ = cmd.Process.Wait()
}()
fmt.Printf("background_interactive=%v\n", IsInteractiveTerminal())
}
func envWithoutCI() []string {
env := make([]string, 0, len(os.Environ()))
for _, entry := range os.Environ() {
if strings.HasPrefix(entry, "CI=") {
continue
}
env = append(env, entry)
}
return env
}