From 8615067df11f18fe73b67d7f14dada8a5d2ca4e0 Mon Sep 17 00:00:00 2001 From: Daniel Liu <139250065@qq.com> Date: Sun, 21 Sep 2025 19:23:41 +0800 Subject: [PATCH] cmd, console, internal: support interrupting the js console #23387 (#1533) --- cmd/XDC/consolecmd.go | 48 +++++++-------- cmd/XDC/main.go | 6 +- cmd/utils/cmd.go | 37 ++++++++---- console/console.go | 132 +++++++++++++++++++++++++++++++++--------- internal/jsre/jsre.go | 42 +++++++++++--- 5 files changed, 195 insertions(+), 70 deletions(-) diff --git a/cmd/XDC/consolecmd.go b/cmd/XDC/consolecmd.go index 759c26f149..9a063a6cc8 100644 --- a/cmd/XDC/consolecmd.go +++ b/cmd/XDC/consolecmd.go @@ -18,12 +18,9 @@ package main import ( "fmt" - "os" - "os/signal" "path/filepath" "slices" "strings" - "syscall" "github.com/XinFinOrg/XDPoSChain/cmd/utils" "github.com/XinFinOrg/XDPoSChain/console" @@ -76,10 +73,10 @@ JavaScript API. See https://github.com/XinFinOrg/XDPoSChain/wiki/JavaScript-Cons func localConsole(ctx *cli.Context) error { // Create and start the node based on the CLI flags stack, backend, cfg := makeFullNode(ctx) - startNode(ctx, stack, backend, cfg) + startNode(ctx, stack, backend, cfg, true) defer stack.Close() - // Attach to the newly started node and start the JavaScript console + // Attach to the newly started node and create the JavaScript console. client := stack.Attach() config := console.Config{ DataDir: utils.MakeDataDir(ctx), @@ -87,22 +84,28 @@ func localConsole(ctx *cli.Context) error { Client: client, Preload: utils.MakeConsolePreloads(ctx), } - console, err := console.New(config) if err != nil { - utils.Fatalf("failed to start the JavaScript console: %v", err) + return fmt.Errorf("failed to start the JavaScript console: %v", err) } defer console.Stop(false) - // If only a short execution was requested, evaluate and return + // If only a short execution was requested, evaluate and return. if script := ctx.String(utils.ExecFlag.Name); script != "" { console.Evaluate(script) return nil } - // Otherwise print the welcome screen and enter interactive mode + + // Track node shutdown and stop the console when it goes down. + // This happens when SIGTERM is sent to the process. + go func() { + stack.Wait() + console.StopInteractive() + }() + + // Print the welcome screen and enter interactive mode. console.Welcome() console.Interactive() - return nil } @@ -139,7 +142,6 @@ func remoteConsole(ctx *cli.Context) error { Client: client, Preload: utils.MakeConsolePreloads(ctx), } - console, err := console.New(config) if err != nil { utils.Fatalf("Failed to start the JavaScript console: %v", err) @@ -154,7 +156,6 @@ func remoteConsole(ctx *cli.Context) error { // Otherwise print the welcome screen and enter interactive mode console.Welcome() console.Interactive() - return nil } @@ -178,7 +179,7 @@ func dialRPC(endpoint string) (*rpc.Client, error) { func ephemeralConsole(ctx *cli.Context) error { // Create and start the node based on the CLI flags stack, backend, cfg := makeFullNode(ctx) - startNode(ctx, stack, backend, cfg) + startNode(ctx, stack, backend, cfg, false) defer stack.Close() // Attach to the newly started node and start the JavaScript console @@ -192,25 +193,24 @@ func ephemeralConsole(ctx *cli.Context) error { console, err := console.New(config) if err != nil { - utils.Fatalf("Failed to start the JavaScript console: %v", err) + return fmt.Errorf("failed to start the JavaScript console: %v", err) } defer console.Stop(false) - // Evaluate each of the specified JavaScript files + // Interrupt the JS interpreter when node is stopped. + go func() { + stack.Wait() + console.Stop(false) + }() + + // Evaluate each of the specified JavaScript files. for _, file := range ctx.Args().Slice() { if err = console.Execute(file); err != nil { - utils.Fatalf("Failed to execute %s: %v", file, err) + return fmt.Errorf("failed to execute %s: %v", file, err) } } - // Wait for pending callbacks, but stop for Ctrl-C. - abort := make(chan os.Signal, 1) - signal.Notify(abort, syscall.SIGINT, syscall.SIGTERM) - go func() { - <-abort - os.Exit(0) - }() + // The main script is now done, but keep running timers/callbacks. console.Stop(true) - return nil } diff --git a/cmd/XDC/main.go b/cmd/XDC/main.go index 8540eb2326..1508b3c35c 100644 --- a/cmd/XDC/main.go +++ b/cmd/XDC/main.go @@ -264,7 +264,7 @@ func main() { func XDC(ctx *cli.Context) error { stack, backend, cfg := makeFullNode(ctx) defer stack.Close() - startNode(ctx, stack, backend, cfg) + startNode(ctx, stack, backend, cfg, false) stack.Wait() return nil } @@ -272,9 +272,9 @@ func XDC(ctx *cli.Context) error { // startNode boots up the system node and all registered protocols, after which // it unlocks any requested accounts, and starts the RPC/IPC interfaces and the // miner. -func startNode(ctx *cli.Context, stack *node.Node, backend ethapi.Backend, cfg XDCConfig) { +func startNode(ctx *cli.Context, stack *node.Node, backend ethapi.Backend, cfg XDCConfig, isConsole bool) { // Start up the node itself - utils.StartNode(stack) + utils.StartNode(stack, isConsole) // Unlock any account specifically requested backends := stack.AccountManager().Backends(keystore.KeyStoreType) diff --git a/cmd/utils/cmd.go b/cmd/utils/cmd.go index 108a4baffc..ceb86453ba 100644 --- a/cmd/utils/cmd.go +++ b/cmd/utils/cmd.go @@ -64,7 +64,7 @@ func Fatalf(format string, args ...interface{}) { os.Exit(1) } -func StartNode(stack *node.Node) { +func StartNode(stack *node.Node, isConsole bool) { if err := stack.Start(); err != nil { Fatalf("Error starting protocol stack: %v", err) } @@ -72,17 +72,34 @@ func StartNode(stack *node.Node) { sigc := make(chan os.Signal, 1) signal.Notify(sigc, syscall.SIGINT, syscall.SIGTERM) defer signal.Stop(sigc) - <-sigc - log.Info("Got interrupt, shutting down...") - go stack.Close() - for i := 10; i > 0; i-- { - <-sigc - if i > 1 { - log.Warn("Already shutting down, interrupt more to panic.", "times", i-1) + + shutdown := func() { + log.Info("Got interrupt, shutting down...") + go stack.Close() + for i := 10; i > 0; i-- { + <-sigc + if i > 1 { + log.Warn("Already shutting down, interrupt more to panic.", "times", i-1) + } } + debug.Exit() // ensure trace and CPU profile data is flushed. + debug.LoudPanic("boom") + } + + if isConsole { + // In JS console mode, SIGINT is ignored because it's handled by the console. + // However, SIGTERM still shuts down the node. + for { + sig := <-sigc + if sig == syscall.SIGTERM { + shutdown() + return + } + } + } else { + <-sigc + shutdown() } - debug.Exit() // ensure trace and CPU profile data is flushed. - debug.LoudPanic("boom") }() } diff --git a/console/console.go b/console/console.go index 08a9aae9ad..b21f4a6528 100644 --- a/console/console.go +++ b/console/console.go @@ -17,6 +17,7 @@ package console import ( + "errors" "fmt" "io" "os" @@ -25,6 +26,7 @@ import ( "regexp" "sort" "strings" + "sync" "syscall" "github.com/XinFinOrg/XDPoSChain/console/prompt" @@ -73,6 +75,13 @@ type Console struct { histPath string // Absolute path to the console scrollback history history []string // Scroll history maintained by the console printer io.Writer // Output writer to serialize any display strings to + + interactiveStopped chan struct{} + stopInteractiveCh chan struct{} + signalReceived chan struct{} + stopped chan struct{} + wg sync.WaitGroup + stopOnce sync.Once } // New initializes a JavaScript interpreted runtime environment and sets defaults @@ -91,12 +100,16 @@ func New(config Config) (*Console, error) { // Initialize the console and return console := &Console{ - client: config.Client, - jsre: jsre.New(config.DocRoot, config.Printer), - prompt: config.Prompt, - prompter: config.Prompter, - printer: config.Printer, - histPath: filepath.Join(config.DataDir, HistoryFile), + client: config.Client, + jsre: jsre.New(config.DocRoot, config.Printer), + prompt: config.Prompt, + prompter: config.Prompter, + printer: config.Printer, + histPath: filepath.Join(config.DataDir, HistoryFile), + interactiveStopped: make(chan struct{}), + stopInteractiveCh: make(chan struct{}), + signalReceived: make(chan struct{}, 1), + stopped: make(chan struct{}), } if err := os.MkdirAll(config.DataDir, 0700); err != nil { return nil, err @@ -104,6 +117,10 @@ func New(config Config) (*Console, error) { if err := console.init(config.Preload); err != nil { return nil, err } + + console.wg.Add(1) + go console.interruptHandler() + return console, nil } @@ -305,44 +322,100 @@ func (c *Console) Evaluate(statement string) { } }() c.jsre.Evaluate(statement, c.printer) + + // Avoid exiting Interactive when jsre was interrupted by SIGINT. + c.clearSignalReceived() } -// Interactive starts an interactive user session, where input is propted from +// interruptHandler runs in its own goroutine and waits for signals. +// When a signal is received, it interrupts the JS interpreter. +func (c *Console) interruptHandler() { + defer c.wg.Done() + + // During Interactive, liner inhibits the signal while it is prompting for + // input. However, the signal will be received while evaluating JS. + // + // On unsupported terminals, SIGINT can also happen while prompting. + // Unfortunately, it is not possible to abort the prompt in this case and + // the c.readLines goroutine leaks. + sig := make(chan os.Signal, 1) + signal.Notify(sig, syscall.SIGINT) + defer signal.Stop(sig) + + for { + select { + case <-sig: + c.setSignalReceived() + c.jsre.Interrupt(errors.New("interrupted")) + case <-c.stopInteractiveCh: + close(c.interactiveStopped) + c.jsre.Interrupt(errors.New("interrupted")) + case <-c.stopped: + return + } + } +} + +func (c *Console) setSignalReceived() { + select { + case c.signalReceived <- struct{}{}: + default: + } +} + +func (c *Console) clearSignalReceived() { + select { + case <-c.signalReceived: + default: + } +} + +// StopInteractive causes Interactive to return as soon as possible. +func (c *Console) StopInteractive() { + select { + case c.stopInteractiveCh <- struct{}{}: + case <-c.stopped: + } +} + +// Interactive starts an interactive user session, where in.put is propted from // the configured user prompter. func (c *Console) Interactive() { var ( - prompt = c.prompt // Current prompt line (used for multi-line inputs) - indents = 0 // Current number of input indents (used for multi-line inputs) - input = "" // Current user input + prompt = c.prompt // the current prompt line (used for multi-line inputs) + indents = 0 // the current number of input indents (used for multi-line inputs) + input = "" // the current user input inputLine = make(chan string, 1) // receives user input inputErr = make(chan error, 1) // receives liner errors requestLine = make(chan string) // requests a line of input - interrupt = make(chan os.Signal, 1) ) - // Monitor Ctrl-C. While liner does turn on the relevant terminal mode bits to avoid - // the signal, a signal can still be received for unsupported terminals. Unfortunately - // there is no way to cancel the line reader when this happens. The readLines - // goroutine will be leaked in this case. - signal.Notify(interrupt, syscall.SIGINT, syscall.SIGTERM) - defer signal.Stop(interrupt) + defer func() { + c.writeHistory() + }() // The line reader runs in a separate goroutine. go c.readLines(inputLine, inputErr, requestLine) defer close(requestLine) - // Start sending prompts to the user and reading back inputs for { // Send the next prompt, triggering an input read. requestLine <- prompt select { - case <-interrupt: + case <-c.interactiveStopped: + fmt.Fprintln(c.printer, "node is down, exiting console") + return + + case <-c.signalReceived: + // SIGINT received while prompting for input -> unsupported terminal. + // I'm not sure if the best choice would be to leave the console running here. + // Bash keeps running in this case. node.js does not. fmt.Fprintln(c.printer, "caught interrupt, exiting") return case err := <-inputErr: - if err == liner.ErrPromptAborted && indents > 0 { + if err == liner.ErrPromptAborted { // When prompting for multi-line input, the first Ctrl-C resets // the multi-line state. prompt, indents, input = c.prompt, 0, "" @@ -445,12 +518,19 @@ func (c *Console) Execute(path string) error { // Stop cleans up the console and terminates the runtime environment. func (c *Console) Stop(graceful bool) error { - if err := os.WriteFile(c.histPath, []byte(strings.Join(c.history, "\n")), 0600); err != nil { - return err - } - if err := os.Chmod(c.histPath, 0600); err != nil { // Force 0600, even if it was different previously - return err - } + c.stopOnce.Do(func() { + // Stop the interrupt handler. + close(c.stopped) + c.wg.Wait() + }) + c.jsre.Stop(graceful) return nil } + +func (c *Console) writeHistory() error { + if err := os.WriteFile(c.histPath, []byte(strings.Join(c.history, "\n")), 0600); err != nil { + return err + } + return os.Chmod(c.histPath, 0600) // Force 0600, even if it was different previously +} diff --git a/internal/jsre/jsre.go b/internal/jsre/jsre.go index 14fc602aac..c44afc205b 100644 --- a/internal/jsre/jsre.go +++ b/internal/jsre/jsre.go @@ -20,6 +20,7 @@ package jsre import ( crand "crypto/rand" "encoding/binary" + "errors" "fmt" "io" "math/rand" @@ -220,19 +221,33 @@ loop: } // Do executes the given function on the JS event loop. +// When the runtime is stopped, fn will not execute. func (re *JSRE) Do(fn func(*goja.Runtime)) { done := make(chan bool) req := &evalReq{fn, done} - re.evalQueue <- req - <-done + select { + case re.evalQueue <- req: + <-done + case <-re.closed: + } } -// stops the event loop before exit, optionally waits for all timers to expire +// Stop terminates the event loop, optionally waiting for all timers to expire. func (re *JSRE) Stop(waitForCallbacks bool) { - select { - case <-re.closed: - case re.stopEventLoop <- waitForCallbacks: - <-re.closed + timeout := time.NewTimer(10 * time.Millisecond) + defer timeout.Stop() + + for { + select { + case <-re.closed: + return + case re.stopEventLoop <- waitForCallbacks: + <-re.closed + return + case <-timeout.C: + // JS is blocked, interrupt and try again. + re.vm.Interrupt(errors.New("JS runtime stopped")) + } } } @@ -282,6 +297,19 @@ func (re *JSRE) Evaluate(code string, w io.Writer) { }) } +// Interrupt stops the current JS evaluation. +func (re *JSRE) Interrupt(v interface{}) { + done := make(chan bool) + noop := func(*goja.Runtime) {} + + select { + case re.evalQueue <- &evalReq{noop, done}: + // event loop is not blocked. + default: + re.vm.Interrupt(v) + } +} + // Compile compiles and then runs a piece of JS code. func (re *JSRE) Compile(filename string, src string) (err error) { re.Do(func(vm *goja.Runtime) { _, err = compileAndRun(vm, filename, src) })