console: print helpful error when attach stdin is not a terminal

When geth attach is started without a TTY (e.g. via screen -X screen or
</dev/null), the console printed the welcome message and then exited
silently. Detect non-interactive stdin early for attach and print a
helpful hint on EOF in Interactive().

Fixes #32387
This commit is contained in:
mehdi salimi 2026-07-03 00:16:22 +03:30
parent 7aa7806c09
commit eff02faba9
4 changed files with 67 additions and 6 deletions

View file

@ -18,11 +18,13 @@ package main
import (
"fmt"
"os"
"slices"
"strings"
"github.com/ethereum/go-ethereum/cmd/utils"
"github.com/ethereum/go-ethereum/console"
"github.com/mattn/go-isatty"
"github.com/urfave/cli/v2"
)
@ -141,11 +143,30 @@ func remoteConsole(ctx *cli.Context) error {
}
// Otherwise print the welcome screen and enter interactive mode
if err := requireInteractiveTTY(); err != nil {
return err
}
console.Welcome()
console.Interactive()
return nil
}
// requireInteractiveTTY ensures stdin is a terminal or an open pipe when starting
// an interactive attach session. Piped stdin is allowed for scripting and tests;
// closed or non-interactive sources such as /dev/null are rejected early.
func requireInteractiveTTY() error {
if isatty.IsTerminal(os.Stdin.Fd()) || isatty.IsCygwinTerminal(os.Stdin.Fd()) {
return nil
}
fi, err := os.Stdin.Stat()
if err == nil && fi.Mode()&os.ModeNamedPipe != 0 {
return nil
}
return fmt.Errorf(`Cannot start interactive console: input closed or not a terminal.
Hint: use 'geth attach --exec "eth.blockNumber" <endpoint>' for non-interactive use,
or run attach inside a TTY (e.g. screen -dmS name bash -lc 'geth attach ...').`)
}
// ephemeralConsole starts a new geth node, attaches an ephemeral JavaScript
// console to it, executes each of the files specified as arguments and tears
// everything down.

View file

@ -119,7 +119,8 @@ func TestAttachWelcome(t *testing.T) {
}
func testAttachWelcome(t *testing.T, geth *testgeth, endpoint, apis string) {
// Attach to a running geth node and terminate immediately
// Attach to a running geth node with stdin closed, as happens when
// launched without a TTY (e.g. via GNU screen -X screen).
attach := runGeth(t, "attach", endpoint)
defer attach.ExpectExit()
attach.CloseStdin()
@ -136,7 +137,7 @@ func testAttachWelcome(t *testing.T, geth *testgeth, endpoint, apis string) {
attach.SetTemplateFunc("datadir", func() string { return geth.Datadir })
attach.SetTemplateFunc("apis", func() string { return apis })
// Verify the actual welcome message to the required template
// Verify the welcome message and non-TTY hint are printed.
attach.Expect(`
Welcome to the Geth JavaScript console!
@ -146,9 +147,11 @@ at block: 0 ({{niltime}}){{if ipc}}
modules: {{apis}}
To exit, press ctrl-d or type exit
> {{.InputLine "exit" }}
>
Cannot start interactive console: input closed or not a terminal.
Hint: use 'geth attach --exec "eth.blockNumber" <endpoint>' for non-interactive use,
or run attach inside a TTY (e.g. screen -dmS name bash -lc 'geth attach ...').
`)
attach.ExpectExit()
}
// trulyRandInt generates a crypto random integer used by the console tests to

View file

@ -429,6 +429,11 @@ func (c *Console) Interactive() {
prompt, indents, input = c.prompt, 0, ""
continue
}
if errors.Is(err, io.EOF) {
fmt.Fprintln(c.printer, "Cannot start interactive console: input closed or not a terminal.")
fmt.Fprintln(c.printer, "Hint: use 'geth attach --exec \"eth.blockNumber\" <endpoint>' for non-interactive use,")
fmt.Fprintln(c.printer, "or run attach inside a TTY (e.g. screen -dmS name bash -lc 'geth attach ...').")
}
return
case line := <-inputLine:

View file

@ -20,6 +20,7 @@ import (
"bytes"
"errors"
"fmt"
"io"
"os"
"strings"
"testing"
@ -72,6 +73,17 @@ func (p *hookedPrompter) AppendHistory(command string) {}
func (p *hookedPrompter) ClearHistory() {}
func (p *hookedPrompter) SetWordCompleter(completer prompt.WordCompleter) {}
// eofPrompter simulates a closed or non-terminal stdin by returning io.EOF.
type eofPrompter struct{}
func (eofPrompter) PromptInput(string) (string, error) { return "", io.EOF }
func (eofPrompter) PromptPassword(string) (string, error) { return "", io.EOF }
func (eofPrompter) PromptConfirm(string) (bool, error) { return false, io.EOF }
func (eofPrompter) SetHistory([]string) {}
func (eofPrompter) AppendHistory(string) {}
func (eofPrompter) ClearHistory() {}
func (eofPrompter) SetWordCompleter(completer prompt.WordCompleter) {}
// tester is a console test environment for the console tests to operate on.
type tester struct {
workspace string
@ -85,6 +97,10 @@ type tester struct {
// newTester creates a test environment based on which the console can operate.
// Please ensure you call Close() on the returned tester to avoid leaks.
func newTester(t *testing.T, confOverride func(*ethconfig.Config)) *tester {
return newTesterWithPrompter(t, &hookedPrompter{scheduler: make(chan string)}, confOverride)
}
func newTesterWithPrompter(t *testing.T, prompter prompt.UserPrompter, confOverride func(*ethconfig.Config)) *tester {
// Create a temporary storage for the node keys and initialize it
workspace := t.TempDir()
@ -115,7 +131,6 @@ func newTester(t *testing.T, confOverride func(*ethconfig.Config)) *tester {
client.Close()
})
prompter := &hookedPrompter{scheduler: make(chan string)}
printer := new(bytes.Buffer)
console, err := New(Config{
@ -130,12 +145,16 @@ func newTester(t *testing.T, confOverride func(*ethconfig.Config)) *tester {
t.Fatalf("failed to create JavaScript console: %v", err)
}
// Create the final tester and return
var input *hookedPrompter
if hp, ok := prompter.(*hookedPrompter); ok {
input = hp
}
return &tester{
workspace: workspace,
stack: stack,
ethereum: ethBackend,
console: console,
input: prompter,
input: input,
output: printer,
}
}
@ -188,6 +207,19 @@ func TestEvaluate(t *testing.T) {
}
}
// Tests that Interactive exits with a helpful message when stdin is closed.
func TestInteractiveEOF(t *testing.T) {
tester := newTesterWithPrompter(t, eofPrompter{}, nil)
defer tester.Close(t)
tester.console.Interactive()
output := tester.output.String()
if want := "not a terminal"; !strings.Contains(output, want) {
t.Fatalf("console output missing EOF hint: have\n%s\nwant substring %s", output, want)
}
}
// Tests that the console can be used in interactive mode.
func TestInteractive(t *testing.T) {
// Create a tester and run an interactive console in the background