Skip to content

Commit

Permalink
Adding Suspend()/Resume() to restore terminal to normal (#24)
Browse files Browse the repository at this point in the history
* Adding .Suspend()/.Resume()

* Adding run cmd to sample to test/verify that Suspend/Resume work

* clear error after fake read, use specific error for stop to avoid logging it info level
  • Loading branch information
ldemailly authored Sep 17, 2024
1 parent 7fa1f01 commit d753b63
Show file tree
Hide file tree
Showing 5 changed files with 86 additions and 7 deletions.
20 changes: 20 additions & 0 deletions example/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import (
"fmt"
"io"
"os"
"os/exec"
"strings"
"time"

Expand All @@ -28,6 +29,7 @@ const (
afterCmd = "after "
sleepCmd = "sleep "
cancelCmd = "cancel " // simulate an external interrupt
runCmd = "run " // run a command after suspending the terminal
exitCmd = "exit"
helpCmd = "help"
testMLCmd = "multiline"
Expand Down Expand Up @@ -190,6 +192,24 @@ func Main() int { //nolint:funlen // long but simple (and some amount of copy pa
}
t.SetPrompt(cmd[len(promptCmd):])
isValidCommand = true
case strings.HasPrefix(cmd, runCmd):
if onlyValid {
t.AddToHistory(cmd)
}
// suspend the terminal, run the command, resume the terminal
t.Suspend()
args := strings.Fields(cmd[len(runCmd):])
// First element is the command, rest are arguments
cmd := exec.Command(args[0], args[1:]...) //nolint:gosec // this is a demo
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
err = cmd.Run()
ctx, cancel = t.Resume(context.Background())
if err != nil {
log.Errf("Error running command %v: %v", args, err)
}
isValidCommand = true
default:
fmt.Fprintf(t.Out, "Unknown command %q\n", cmd)
}
Expand Down
4 changes: 2 additions & 2 deletions go.mod
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
module fortio.org/terminal

go 1.22.6
go 1.22.7

require (
fortio.org/cli v1.9.0
Expand All @@ -15,5 +15,5 @@ require (
fortio.org/struct2env v0.4.1 // indirect
fortio.org/version v1.0.4 // indirect
github.com/kortschak/goroutine v1.1.2 // indirect
golang.org/x/crypto/x509roots/fallback v0.0.0-20240806160748-b2d3a6a4b4d3 // indirect
golang.org/x/crypto/x509roots/fallback v0.0.0-20240916204253-42ee18b96377 // indirect
)
4 changes: 2 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ fortio.org/version v1.0.4 h1:FWUMpJ+hVTNc4RhvvOJzb0xesrlRmG/a+D6bjbQ4+5U=
fortio.org/version v1.0.4/go.mod h1:2JQp9Ax+tm6QKiGuzR5nJY63kFeANcgrZ0osoQFDVm0=
github.com/kortschak/goroutine v1.1.2 h1:lhllcCuERxMIK5cYr8yohZZScL1na+JM5JYPRclWjck=
github.com/kortschak/goroutine v1.1.2/go.mod h1:zKpXs1FWN/6mXasDQzfl7g0LrGFIOiA6cLs9eXKyaMY=
golang.org/x/crypto/x509roots/fallback v0.0.0-20240806160748-b2d3a6a4b4d3 h1:oWb21rU9Q9XrRwXLB7jHc1rbp6EiiimZZv5MLxpu4T0=
golang.org/x/crypto/x509roots/fallback v0.0.0-20240806160748-b2d3a6a4b4d3/go.mod h1:kNa9WdvYnzFwC79zRpLRMJbdEFlhyM5RPFBBZp/wWH8=
golang.org/x/crypto/x509roots/fallback v0.0.0-20240916204253-42ee18b96377 h1:aDWu69N3Si4isYMY1ppnuoGEFypX/E5l4MWA//GPClw=
golang.org/x/crypto/x509roots/fallback v0.0.0-20240916204253-42ee18b96377/go.mod h1:kNa9WdvYnzFwC79zRpLRMJbdEFlhyM5RPFBBZp/wWH8=
golang.org/x/sys v0.25.0 h1:r+8e+loiHxRqhXVl6ML1nO3l1+oFoWbnlu2Ehimmi34=
golang.org/x/sys v0.25.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
41 changes: 38 additions & 3 deletions interrupt.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package terminal
import (
"bytes"
"context"
"errors"
"io"
"os"
"os/signal"
Expand All @@ -23,9 +24,13 @@ type InterruptReader struct {
mu sync.Mutex
cond sync.Cond
cancel context.CancelFunc
stopped bool
}

var ErrUserInterrupt = NewErrInterrupted("terminal interrupted by user")
var (
ErrUserInterrupt = NewErrInterrupted("terminal interrupted by user")
ErrStopped = NewErrInterrupted("interrupt reader stopped") // not really an error more of a marker.
)

type InterruptedError struct {
DetailedReason string
Expand Down Expand Up @@ -67,10 +72,31 @@ func NewInterruptReader(reader *os.File, bufSize int) *InterruptReader {
return ir
}

func (ir *InterruptReader) Stop() {
log.Debugf("InterruptReader stopping")
ir.mu.Lock()
if ir.cancel == nil {
ir.mu.Unlock()
return
}
ir.cancel()
ir.stopped = true
ir.cancel = nil
ir.mu.Unlock()
_, _ = ir.Read([]byte{}) // wait for cancel.
log.Debugf("InterruptReader done stopping")
ir.mu.Lock()
ir.buf = ir.reset
ir.err = nil
ir.mu.Unlock()
}

// Start or restart (after a cancel/interrupt) the interrupt reader.
func (ir *InterruptReader) Start(ctx context.Context) (context.Context, context.CancelFunc) {
log.Debugf("InterruptReader starting")
ir.mu.Lock()
defer ir.mu.Unlock()
ir.stopped = false
if ir.cancel != nil {
ir.cancel()
}
Expand Down Expand Up @@ -118,7 +144,12 @@ func (ir *InterruptReader) start(ctx context.Context) {
ir.cancel()
return
case <-ctx.Done():
ir.setError(NewErrInterruptedWithErr("context done", ctx.Err()))
if ir.stopped {
ir.setError(ErrStopped)
ir.cond.Broadcast()
} else {
ir.setError(NewErrInterruptedWithErr("context done", ctx.Err()))
}
return
default:
n, err := TimeoutReader(ir.fd, tv, localBuf)
Expand Down Expand Up @@ -150,7 +181,11 @@ func (ir *InterruptReader) start(ctx context.Context) {
}

func (ir *InterruptReader) setError(err error) {
log.Infof("InterruptReader setting error: %v", err)
level := log.Info
if errors.Is(err, ErrStopped) {
level = log.Verbose
}
log.S(level, "InterruptReader setting error", log.Any("err", err))
ir.mu.Lock()
ir.err = err
ir.mu.Unlock()
Expand Down
24 changes: 24 additions & 0 deletions terminal.go
Original file line number Diff line number Diff line change
Expand Up @@ -215,6 +215,30 @@ func saveHistory(f string, h []string) {
}
}

// Temporarily suspend/resume of the terminal back to normal (for example to run a sub process).
// use defer t.Resume() after calling Suspend() to put the terminal back in raw mode.
func (t *Terminal) Suspend() {
if t.oldState == nil {
return
}
t.intrReader.Stop() // stop the interrupt reader
err := term.Restore(t.fd, t.oldState)
if err != nil {
log.Errf("Error restoring terminal for suspend: %v", err)
}
}

func (t *Terminal) Resume(ctx context.Context) (context.Context, context.CancelFunc) {
if t.oldState == nil {
return nil, nil
}
_, err := term.MakeRaw(t.fd)
if err != nil {
log.Errf("Error for terminal resume: %v", err)
}
return t.ResetInterrupts(ctx) // resume the interrupt reader
}

// Close restores the terminal to its original state. Must be called at exit to avoid leaving
// the terminal in raw mode. Safe to call multiple times. Will save the history to the history file
// if one was set using [SetHistoryFile] and the capacity is > 0.
Expand Down

0 comments on commit d753b63

Please sign in to comment.