Skip to content

Commit 72c2c0a

Browse files
holimans1nafjl
authored
cmd/geth, console: support interrupting the js console (ethereum#23387)
Previously, Ctrl-C (SIGINT) was ignored during JS execution, so it was not possible to get out of infinite loops in the console. With this change, Ctrl-C now interrupts JS. Fixes ethereum#23344 Co-authored-by: Sina Mahmoodi <[email protected]> Co-authored-by: Felix Lange <[email protected]>
1 parent ae8ff26 commit 72c2c0a

File tree

5 files changed

+191
-61
lines changed

5 files changed

+191
-61
lines changed

Diff for: cmd/geth/consolecmd.go

+26-21
Original file line numberDiff line numberDiff line change
@@ -77,43 +77,48 @@ func localConsole(ctx *cli.Context) error {
7777
// Create and start the node based on the CLI flags
7878
prepare(ctx)
7979
stack, backend := makeFullNode(ctx)
80-
startNode(ctx, stack, backend)
80+
startNode(ctx, stack, backend, true)
8181
defer stack.Close()
8282

83-
// Attach to the newly started node and start the JavaScript console
83+
// Attach to the newly started node and create the JavaScript console.
8484
client, err := stack.Attach()
8585
if err != nil {
86-
utils.Fatalf("Failed to attach to the inproc geth: %v", err)
86+
return fmt.Errorf("Failed to attach to the inproc geth: %v", err)
8787
}
8888
config := console.Config{
8989
DataDir: utils.MakeDataDir(ctx),
9090
DocRoot: ctx.GlobalString(utils.JSpathFlag.Name),
9191
Client: client,
9292
Preload: utils.MakeConsolePreloads(ctx),
9393
}
94-
9594
console, err := console.New(config)
9695
if err != nil {
97-
utils.Fatalf("Failed to start the JavaScript console: %v", err)
96+
return fmt.Errorf("Failed to start the JavaScript console: %v", err)
9897
}
9998
defer console.Stop(false)
10099

101-
// If only a short execution was requested, evaluate and return
100+
// If only a short execution was requested, evaluate and return.
102101
if script := ctx.GlobalString(utils.ExecFlag.Name); script != "" {
103102
console.Evaluate(script)
104103
return nil
105104
}
106-
// Otherwise print the welcome screen and enter interactive mode
105+
106+
// Track node shutdown and stop the console when it goes down.
107+
// This happens when SIGTERM is sent to the process.
108+
go func() {
109+
stack.Wait()
110+
console.StopInteractive()
111+
}()
112+
113+
// Print the welcome screen and enter interactive mode.
107114
console.Welcome()
108115
console.Interactive()
109-
110116
return nil
111117
}
112118

113119
// remoteConsole will connect to a remote geth instance, attaching a JavaScript
114120
// console to it.
115121
func remoteConsole(ctx *cli.Context) error {
116-
// Attach to a remotely running geth instance and start the JavaScript console
117122
endpoint := ctx.Args().First()
118123
if endpoint == "" {
119124
path := node.DefaultDataDir()
@@ -150,7 +155,6 @@ func remoteConsole(ctx *cli.Context) error {
150155
Client: client,
151156
Preload: utils.MakeConsolePreloads(ctx),
152157
}
153-
154158
console, err := console.New(config)
155159
if err != nil {
156160
utils.Fatalf("Failed to start the JavaScript console: %v", err)
@@ -165,7 +169,6 @@ func remoteConsole(ctx *cli.Context) error {
165169
// Otherwise print the welcome screen and enter interactive mode
166170
console.Welcome()
167171
console.Interactive()
168-
169172
return nil
170173
}
171174

@@ -189,13 +192,13 @@ func dialRPC(endpoint string) (*rpc.Client, error) {
189192
func ephemeralConsole(ctx *cli.Context) error {
190193
// Create and start the node based on the CLI flags
191194
stack, backend := makeFullNode(ctx)
192-
startNode(ctx, stack, backend)
195+
startNode(ctx, stack, backend, false)
193196
defer stack.Close()
194197

195198
// Attach to the newly started node and start the JavaScript console
196199
client, err := stack.Attach()
197200
if err != nil {
198-
utils.Fatalf("Failed to attach to the inproc geth: %v", err)
201+
return fmt.Errorf("Failed to attach to the inproc geth: %v", err)
199202
}
200203
config := console.Config{
201204
DataDir: utils.MakeDataDir(ctx),
@@ -206,22 +209,24 @@ func ephemeralConsole(ctx *cli.Context) error {
206209

207210
console, err := console.New(config)
208211
if err != nil {
209-
utils.Fatalf("Failed to start the JavaScript console: %v", err)
212+
return fmt.Errorf("Failed to start the JavaScript console: %v", err)
210213
}
211214
defer console.Stop(false)
212215

213-
// Evaluate each of the specified JavaScript files
216+
// Interrupt the JS interpreter when node is stopped.
217+
go func() {
218+
stack.Wait()
219+
console.Stop(false)
220+
}()
221+
222+
// Evaluate each of the specified JavaScript files.
214223
for _, file := range ctx.Args() {
215224
if err = console.Execute(file); err != nil {
216-
utils.Fatalf("Failed to execute %s: %v", file, err)
225+
return fmt.Errorf("Failed to execute %s: %v", file, err)
217226
}
218227
}
219228

220-
go func() {
221-
stack.Wait()
222-
console.Stop(false)
223-
}()
229+
// The main script is now done, but keep running timers/callbacks.
224230
console.Stop(true)
225-
226231
return nil
227232
}

Diff for: cmd/geth/main.go

+3-3
Original file line numberDiff line numberDiff line change
@@ -320,19 +320,19 @@ func geth(ctx *cli.Context) error {
320320
stack, backend := makeFullNode(ctx)
321321
defer stack.Close()
322322

323-
startNode(ctx, stack, backend)
323+
startNode(ctx, stack, backend, false)
324324
stack.Wait()
325325
return nil
326326
}
327327

328328
// startNode boots up the system node and all registered protocols, after which
329329
// it unlocks any requested accounts, and starts the RPC/IPC interfaces and the
330330
// miner.
331-
func startNode(ctx *cli.Context, stack *node.Node, backend ethapi.Backend) {
331+
func startNode(ctx *cli.Context, stack *node.Node, backend ethapi.Backend, isConsole bool) {
332332
debug.Memsize.Add("node", stack)
333333

334334
// Start up the node itself
335-
utils.StartNode(ctx, stack)
335+
utils.StartNode(ctx, stack, isConsole)
336336

337337
// Unlock any account specifically requested
338338
unlockAccounts(ctx, stack)

Diff for: cmd/utils/cmd.go

+26-10
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,7 @@ func Fatalf(format string, args ...interface{}) {
6868
os.Exit(1)
6969
}
7070

71-
func StartNode(ctx *cli.Context, stack *node.Node) {
71+
func StartNode(ctx *cli.Context, stack *node.Node, isConsole bool) {
7272
if err := stack.Start(); err != nil {
7373
Fatalf("Error starting protocol stack: %v", err)
7474
}
@@ -87,17 +87,33 @@ func StartNode(ctx *cli.Context, stack *node.Node) {
8787
go monitorFreeDiskSpace(sigc, stack.InstanceDir(), uint64(minFreeDiskSpace)*1024*1024)
8888
}
8989

90-
<-sigc
91-
log.Info("Got interrupt, shutting down...")
92-
go stack.Close()
93-
for i := 10; i > 0; i-- {
94-
<-sigc
95-
if i > 1 {
96-
log.Warn("Already shutting down, interrupt more to panic.", "times", i-1)
90+
shutdown := func() {
91+
log.Info("Got interrupt, shutting down...")
92+
go stack.Close()
93+
for i := 10; i > 0; i-- {
94+
<-sigc
95+
if i > 1 {
96+
log.Warn("Already shutting down, interrupt more to panic.", "times", i-1)
97+
}
9798
}
99+
debug.Exit() // ensure trace and CPU profile data is flushed.
100+
debug.LoudPanic("boom")
101+
}
102+
103+
if isConsole {
104+
// In JS console mode, SIGINT is ignored because it's handled by the console.
105+
// However, SIGTERM still shuts down the node.
106+
for {
107+
sig := <-sigc
108+
if sig == syscall.SIGTERM {
109+
shutdown()
110+
return
111+
}
112+
}
113+
} else {
114+
<-sigc
115+
shutdown()
98116
}
99-
debug.Exit() // ensure trace and CPU profile data is flushed.
100-
debug.LoudPanic("boom")
101117
}()
102118
}
103119

Diff for: console/console.go

+101-20
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
package console
1818

1919
import (
20+
"errors"
2021
"fmt"
2122
"io"
2223
"io/ioutil"
@@ -26,6 +27,7 @@ import (
2627
"regexp"
2728
"sort"
2829
"strings"
30+
"sync"
2931
"syscall"
3032

3133
"github.com/dop251/goja"
@@ -74,6 +76,13 @@ type Console struct {
7476
histPath string // Absolute path to the console scrollback history
7577
history []string // Scroll history maintained by the console
7678
printer io.Writer // Output writer to serialize any display strings to
79+
80+
interactiveStopped chan struct{}
81+
stopInteractiveCh chan struct{}
82+
signalReceived chan struct{}
83+
stopped chan struct{}
84+
wg sync.WaitGroup
85+
stopOnce sync.Once
7786
}
7887

7988
// New initializes a JavaScript interpreted runtime environment and sets defaults
@@ -92,19 +101,27 @@ func New(config Config) (*Console, error) {
92101

93102
// Initialize the console and return
94103
console := &Console{
95-
client: config.Client,
96-
jsre: jsre.New(config.DocRoot, config.Printer),
97-
prompt: config.Prompt,
98-
prompter: config.Prompter,
99-
printer: config.Printer,
100-
histPath: filepath.Join(config.DataDir, HistoryFile),
104+
client: config.Client,
105+
jsre: jsre.New(config.DocRoot, config.Printer),
106+
prompt: config.Prompt,
107+
prompter: config.Prompter,
108+
printer: config.Printer,
109+
histPath: filepath.Join(config.DataDir, HistoryFile),
110+
interactiveStopped: make(chan struct{}),
111+
stopInteractiveCh: make(chan struct{}),
112+
signalReceived: make(chan struct{}, 1),
113+
stopped: make(chan struct{}),
101114
}
102115
if err := os.MkdirAll(config.DataDir, 0700); err != nil {
103116
return nil, err
104117
}
105118
if err := console.init(config.Preload); err != nil {
106119
return nil, err
107120
}
121+
122+
console.wg.Add(1)
123+
go console.interruptHandler()
124+
108125
return console, nil
109126
}
110127

@@ -337,9 +354,63 @@ func (c *Console) Evaluate(statement string) {
337354
}
338355
}()
339356
c.jsre.Evaluate(statement, c.printer)
357+
358+
// Avoid exiting Interactive when jsre was interrupted by SIGINT.
359+
c.clearSignalReceived()
360+
}
361+
362+
// interruptHandler runs in its own goroutine and waits for signals.
363+
// When a signal is received, it interrupts the JS interpreter.
364+
func (c *Console) interruptHandler() {
365+
defer c.wg.Done()
366+
367+
// During Interactive, liner inhibits the signal while it is prompting for
368+
// input. However, the signal will be received while evaluating JS.
369+
//
370+
// On unsupported terminals, SIGINT can also happen while prompting.
371+
// Unfortunately, it is not possible to abort the prompt in this case and
372+
// the c.readLines goroutine leaks.
373+
sig := make(chan os.Signal, 1)
374+
signal.Notify(sig, syscall.SIGINT)
375+
defer signal.Stop(sig)
376+
377+
for {
378+
select {
379+
case <-sig:
380+
c.setSignalReceived()
381+
c.jsre.Interrupt(errors.New("interrupted"))
382+
case <-c.stopInteractiveCh:
383+
close(c.interactiveStopped)
384+
c.jsre.Interrupt(errors.New("interrupted"))
385+
case <-c.stopped:
386+
return
387+
}
388+
}
389+
}
390+
391+
func (c *Console) setSignalReceived() {
392+
select {
393+
case c.signalReceived <- struct{}{}:
394+
default:
395+
}
396+
}
397+
398+
func (c *Console) clearSignalReceived() {
399+
select {
400+
case <-c.signalReceived:
401+
default:
402+
}
340403
}
341404

342-
// Interactive starts an interactive user session, where input is propted from
405+
// StopInteractive causes Interactive to return as soon as possible.
406+
func (c *Console) StopInteractive() {
407+
select {
408+
case c.stopInteractiveCh <- struct{}{}:
409+
case <-c.stopped:
410+
}
411+
}
412+
413+
// Interactive starts an interactive user session, where in.put is propted from
343414
// the configured user prompter.
344415
func (c *Console) Interactive() {
345416
var (
@@ -349,15 +420,11 @@ func (c *Console) Interactive() {
349420
inputLine = make(chan string, 1) // receives user input
350421
inputErr = make(chan error, 1) // receives liner errors
351422
requestLine = make(chan string) // requests a line of input
352-
interrupt = make(chan os.Signal, 1)
353423
)
354424

355-
// Monitor Ctrl-C. While liner does turn on the relevant terminal mode bits to avoid
356-
// the signal, a signal can still be received for unsupported terminals. Unfortunately
357-
// there is no way to cancel the line reader when this happens. The readLines
358-
// goroutine will be leaked in this case.
359-
signal.Notify(interrupt, syscall.SIGINT, syscall.SIGTERM)
360-
defer signal.Stop(interrupt)
425+
defer func() {
426+
c.writeHistory()
427+
}()
361428

362429
// The line reader runs in a separate goroutine.
363430
go c.readLines(inputLine, inputErr, requestLine)
@@ -368,7 +435,14 @@ func (c *Console) Interactive() {
368435
requestLine <- prompt
369436

370437
select {
371-
case <-interrupt:
438+
case <-c.interactiveStopped:
439+
fmt.Fprintln(c.printer, "node is down, exiting console")
440+
return
441+
442+
case <-c.signalReceived:
443+
// SIGINT received while prompting for input -> unsupported terminal.
444+
// I'm not sure if the best choice would be to leave the console running here.
445+
// Bash keeps running in this case. node.js does not.
372446
fmt.Fprintln(c.printer, "caught interrupt, exiting")
373447
return
374448

@@ -476,12 +550,19 @@ func (c *Console) Execute(path string) error {
476550

477551
// Stop cleans up the console and terminates the runtime environment.
478552
func (c *Console) Stop(graceful bool) error {
553+
c.stopOnce.Do(func() {
554+
// Stop the interrupt handler.
555+
close(c.stopped)
556+
c.wg.Wait()
557+
})
558+
559+
c.jsre.Stop(graceful)
560+
return nil
561+
}
562+
563+
func (c *Console) writeHistory() error {
479564
if err := ioutil.WriteFile(c.histPath, []byte(strings.Join(c.history, "\n")), 0600); err != nil {
480565
return err
481566
}
482-
if err := os.Chmod(c.histPath, 0600); err != nil { // Force 0600, even if it was different previously
483-
return err
484-
}
485-
c.jsre.Stop(graceful)
486-
return nil
567+
return os.Chmod(c.histPath, 0600) // Force 0600, even if it was different previously
487568
}

0 commit comments

Comments
 (0)