Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -312,6 +312,11 @@ $ ./starcli --log-file run.log --log-format json job.star
interaction and errors — to a transcript file (appended, with a timestamped
session header), while still showing it live. Handy for replay and review.

REPL prompts and input echoes are included, so terminal transcripts can contain
ANSI control sequences. Recording errors produce a non-zero exit status; an
existing script error keeps its original exit code. If recording fails during
execution, output continues to drain and the failure is reported at shutdown.

```bash
$ ./starcli --record session.log job.star # works in REPL mode too
$ cat session.log
Expand Down
4 changes: 4 additions & 0 deletions cli/characterization_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -47,10 +47,14 @@ func captureStd(t *testing.T, f func()) (stdout, stderr string) {
if err != nil {
t.Fatalf("pipe: %v", err)
}
defer rOut.Close()
defer wOut.Close()
rErr, wErr, err := os.Pipe()
if err != nil {
t.Fatalf("pipe: %v", err)
}
defer rErr.Close()
defer wErr.Close()
os.Stdout, os.Stderr = wOut, wErr
outC, errC := make(chan string), make(chan string)
go func() { var b bytes.Buffer; _, _ = io.Copy(&b, rOut); outC <- b.String() }()
Expand Down
4 changes: 2 additions & 2 deletions cli/dispatch.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,15 +24,15 @@ const (

// Process routes the parsed arguments to the desired run mode and returns the
// process exit code.
func Process(args *Args) int {
func Process(args *Args) (code int) {
// optionally record the whole session (stdout+stderr) to a transcript file
if args.Record != "" {
stop, err := startRecording(args.Record)
if err != nil {
fmt.Fprintln(os.Stderr, err)
return exitError
}
defer stop()
defer func() { code = finishRecording(stop, code) }()
}

// for basic checks
Expand Down
100 changes: 81 additions & 19 deletions cli/record.go
Original file line number Diff line number Diff line change
@@ -1,21 +1,25 @@
package cli

import (
"errors"
"fmt"
"io"
"os"
"path/filepath"
"sync"
"time"

"github.com/chzyer/readline"
)

// startRecording tees the process's stdout and stderr to a transcript file, so a
// whole session — print output, results, REPL interaction and errors — is saved
// for later review. It returns a stop function that restores the streams and
// closes the file; call it before the process exits. stdin is left untouched, so
// closes the file and reports write/close errors; call it before the process
// exits. stdin and readline's terminal writers are preserved, so
// an interactive REPL keeps its terminal. The file is appended to, with a
// timestamped header delimiting each session.
func startRecording(path string) (stop func(), err error) {
func startRecording(path string) (stop func() error, err error) {
if dir := filepath.Dir(path); dir != "" && dir != "." {
if err := os.MkdirAll(dir, 0o755); err != nil {
return nil, fmt.Errorf("record: %w", err)
Expand All @@ -25,46 +29,104 @@ func startRecording(path string) (stop func(), err error) {
if err != nil {
return nil, fmt.Errorf("record: %w", err)
}
fmt.Fprintf(f, "\n===== starcli session %s =====\n", time.Now().Format(time.RFC3339))
return recordTo(f, os.Pipe)
}

func recordTo(f io.WriteCloser, pipe func() (*os.File, *os.File, error)) (func() error, error) {
lf := &lockedWriter{w: f}
fmt.Fprintf(lf, "\n===== starcli session %s =====\n", time.Now().Format(time.RFC3339))
if lf.err != nil {
_ = f.Close()
return nil, fmt.Errorf("record: %w", lf.err)
}

lf := &lockedWriter{w: f} // serialize the two streams into one file
origOut, origErr := os.Stdout, os.Stderr
rOut, wOut, err := os.Pipe()
rOut, wOut, err := pipe()
if err != nil {
_ = f.Close()
return nil, fmt.Errorf("record: %w", err)
}
rErr, wErr, err := os.Pipe()
rErr, wErr, err := pipe()
if err != nil {
_ = rOut.Close()
_ = wOut.Close()
_ = f.Close()
return nil, fmt.Errorf("record: %w", err)
}
os.Stdout, os.Stderr = wOut, wErr
// readline captures its own writers at package initialization. Tee those
// separately, preserving its platform-specific terminal/ANSI handling.
origTermOut, origTermErr := readline.Stdout, readline.Stderr
readline.Stdout = borrowedWriter{io.MultiWriter(origTermOut, lf)}
readline.Stderr = borrowedWriter{io.MultiWriter(origTermErr, lf)}

var wg sync.WaitGroup
var copyErrors [2]error
wg.Add(2)
go func() { defer wg.Done(); _, _ = io.Copy(io.MultiWriter(origOut, lf), rOut) }()
go func() { defer wg.Done(); _, _ = io.Copy(io.MultiWriter(origErr, lf), rErr) }()
go func() {
defer wg.Done()
defer rOut.Close()
_, copyErrors[0] = io.Copy(io.MultiWriter(origOut, lf), rOut)
}()
go func() {
defer wg.Done()
defer rErr.Close()
_, copyErrors[1] = io.Copy(io.MultiWriter(origErr, lf), rErr)
}()

return func() {
os.Stdout, os.Stderr = origOut, origErr
_ = wOut.Close()
_ = wErr.Close()
wg.Wait()
_ = f.Close()
var once sync.Once
var stopErr error
return func() error {
once.Do(func() {
os.Stdout, os.Stderr = origOut, origErr
readline.Stdout, readline.Stderr = origTermOut, origTermErr
_ = wOut.Close()
_ = wErr.Close()
wg.Wait()
lf.mu.Lock()
err := errors.Join(lf.err, copyErrors[0], copyErrors[1], f.Close())
lf.mu.Unlock()
if err != nil {
stopErr = fmt.Errorf("record: %w", err)
}
})
return stopErr
}, nil
}

// lockedWriter serializes concurrent writes (the stdout and stderr copiers) to a
// single underlying writer so transcript lines are not interleaved mid-write.
func finishRecording(stop func() error, code int) int {
if err := stop(); err != nil {
fmt.Fprintln(os.Stderr, err)
if code == exitOK {
return exitError
}
}
return code
}

// borrowedWriter must not close the caller's terminal stream.
type borrowedWriter struct{ io.Writer }

func (borrowedWriter) Close() error { return nil }

// lockedWriter serializes transcript writes and retains the first failure.
// It keeps accepting bytes after a recording failure so the pipe readers keep
// draining and a full disk cannot deadlock the script. stop reports the error.
type lockedWriter struct {
mu sync.Mutex
w io.Writer
mu sync.Mutex
w io.Writer
err error
}

func (l *lockedWriter) Write(p []byte) (int, error) {
l.mu.Lock()
defer l.mu.Unlock()
return l.w.Write(p)
if l.err == nil {
var n int
n, l.err = l.w.Write(p)
if l.err == nil && n != len(p) {
l.err = io.ErrShortWrite
}
}
return len(p), nil
}
Loading
Loading