diff --git a/go.mod b/go.mod index c27f93bf..41605b8e 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/go-python/gpython -go 1.18 +go 1.25 require ( github.com/google/go-cmp v0.5.8 diff --git a/main.go b/main.go index 8b55ab1e..96eeda91 100644 --- a/main.go +++ b/main.go @@ -11,6 +11,7 @@ import ( "fmt" "log" "os" + "os/signal" "runtime" "runtime/pprof" @@ -48,6 +49,14 @@ func xmain(args []string) { ctx := py.NewContext(opts) defer ctx.Close() + sigCh := make(chan os.Signal, 1) + signal.Notify(sigCh, os.Interrupt) + go func() { + for range sigCh { + ctx.SetInterrupt() + } + }() + if *cpuprofile != "" { f, err := os.Create(*cpuprofile) if err != nil { diff --git a/py/run.go b/py/run.go index cd584fc2..7e0e7164 100644 --- a/py/run.go +++ b/py/run.go @@ -42,6 +42,14 @@ type Context interface { // Gereric access to this context's modules / state. Store() *ModuleStore + // SetInterrupt signals the VM to raise KeyboardInterrupt at the next opcode boundary. + // Safe to call from any goroutine (e.g. a signal handler). + SetInterrupt() + + // CheckInterrupt atomically checks and clears the interrupt flag. + // Returns true if an interrupt was pending. + CheckInterrupt() bool + // Close signals this context is about to go out of scope and any internal resources should be released. // Code execution on a py.Context that has been closed will result in an error. Close() error diff --git a/repl/cli/cli.go b/repl/cli/cli.go index f6f2f6c0..a0a6c71f 100644 --- a/repl/cli/cli.go +++ b/repl/cli/cli.go @@ -51,6 +51,7 @@ func newReadline(repl *repl.REPL) *readline { } rl.SetTabCompletionStyle(liner.TabPrints) rl.SetWordCompleter(rl.Completer) + rl.SetCtrlCAborts(true) return rl } @@ -146,6 +147,11 @@ func RunREPL(replCtx *repl.REPL) error { fmt.Printf("\n") break } + if err == liner.ErrPromptAborted { + fmt.Println("KeyboardInterrupt") + rl.repl.ResetContinuation() + continue + } fmt.Printf("Problem reading line: %v\n", err) continue } diff --git a/repl/repl.go b/repl/repl.go index 3938a7b6..3a4d9aed 100644 --- a/repl/repl.go +++ b/repl/repl.go @@ -65,6 +65,14 @@ func (r *REPL) SetUI(term UI) { r.term.SetPrompt(NormalPrompt) } +// ResetContinuation cancels any multi-line input in progress, +// restoring the REPL to a clean prompt state (e.g. after Ctrl+C). +func (r *REPL) ResetContinuation() { + r.continuation = false + r.previous = "" + r.term.SetPrompt(NormalPrompt) +} + // Run runs a single line of the REPL func (r *REPL) Run(line string) error { // Override the PrintExpr output temporarily @@ -112,6 +120,10 @@ func (r *REPL) Run(line string) error { if py.IsException(py.SystemExit, err) { return err } + if py.IsException(py.KeyboardInterrupt, err) { + r.term.Print("KeyboardInterrupt") + return nil + } py.TracebackDump(err) } return nil diff --git a/stdlib/stdlib.go b/stdlib/stdlib.go index 7d1fb811..514cc7c8 100644 --- a/stdlib/stdlib.go +++ b/stdlib/stdlib.go @@ -13,6 +13,7 @@ import ( "path/filepath" "strings" "sync" + "sync/atomic" "github.com/go-python/gpython/py" "github.com/go-python/gpython/stdlib/marshal" @@ -44,6 +45,7 @@ type context struct { closed bool running sync.WaitGroup done chan struct{} + interrupt atomic.Int32 // non-zero means KeyboardInterrupt pending } // NewContext creates a new gpython interpreter instance context. @@ -192,6 +194,16 @@ func (ctx *context) ResolveAndCompile(pathname string, opts py.CompileOpts) (py. return out, nil } +// See interface py.Context defined in py/run.go +func (ctx *context) SetInterrupt() { + ctx.interrupt.Store(1) +} + +// See interface py.Context defined in py/run.go +func (ctx *context) CheckInterrupt() bool { + return ctx.interrupt.Swap(0) != 0 +} + func (ctx *context) pushBusy() error { if ctx.closed { return py.ExceptionNewf(py.RuntimeError, "Context closed") @@ -208,6 +220,7 @@ func (ctx *context) popBusy() { func (ctx *context) Close() error { ctx.closeOnce.Do(func() { ctx.closing = true + ctx.SetInterrupt() ctx.running.Wait() ctx.closed = true diff --git a/vm/eval.go b/vm/eval.go index 9db0fae9..a27f82c0 100644 --- a/vm/eval.go +++ b/vm/eval.go @@ -1751,7 +1751,6 @@ func RunFrame(frame *py.Frame) (res py.Object, err error) { frame: frame, context: frame.Context, } - // FIXME need to do this to save the old exeption when we // yield from a generator. Should save it in the Frame though // (see slots in the frame) @@ -1778,6 +1777,13 @@ func RunFrame(frame *py.Frame) (res py.Object, err error) { var arg int32 opcodes := frame.Code.Code for vm.why == whyNot { + // Check for pending interrupt (e.g. SIGINT / Context.SetInterrupt). + // Routed through the normal exception mechanism so that + // try/except/finally blocks are honored. + if vm.context != nil && vm.context.CheckInterrupt() { + vm.SetException(py.MakeException(py.ExceptionNewf(py.KeyboardInterrupt, "KeyboardInterrupt"))) + goto handleException + } if debugging { debugf("* %4d:", frame.Lasti) } @@ -1822,7 +1828,7 @@ func RunFrame(frame *py.Frame) (res py.Object, err error) { if vm.why == whyYield { goto fast_yield } - + handleException: // Something exceptional has happened - unwind the block stack // and find out what for vm.why != whyNot && frame.Block != nil {