mirror of
https://github.com/lightninglabs/loop.git
synced 2026-08-13 12:33:03 +02:00
cmd/loop: add session recorder plumbing
This commit is contained in:
parent
c3e102527a
commit
137097915c
3 changed files with 1168 additions and 7 deletions
136
cmd/loop/main.go
136
cmd/loop/main.go
|
|
@ -8,9 +8,11 @@ import (
|
|||
"fmt"
|
||||
"io/ioutil"
|
||||
"os"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/btcsuite/btcd/btcutil"
|
||||
|
|
@ -103,8 +105,50 @@ var (
|
|||
|
||||
// cliClock provides the time source used by CLI commands.
|
||||
cliClock clock.Clock = clock.NewDefaultClock()
|
||||
|
||||
// sessionRec is the active recorder when session capture is enabled.
|
||||
sessionRec *sessionRecorder
|
||||
|
||||
// forceDeterministicJSON is enabled by tests to obtain stable JSON
|
||||
// output.
|
||||
forceDeterministicJSON bool
|
||||
)
|
||||
|
||||
// installSessionSignalHandler records signals and cancels the root context.
|
||||
func installSessionSignalHandler(cancel context.CancelFunc) func() {
|
||||
if sessionRec == nil {
|
||||
return func() {}
|
||||
}
|
||||
|
||||
sigCh := make(chan os.Signal, 1)
|
||||
signal.Notify(sigCh, os.Interrupt, syscall.SIGTERM)
|
||||
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
defer close(done)
|
||||
interrupted := false
|
||||
for sig := range sigCh {
|
||||
sessionRec.LogSignal(sig)
|
||||
if !interrupted {
|
||||
interrupted = true
|
||||
cancel()
|
||||
continue
|
||||
}
|
||||
|
||||
// A second signal requests immediate shutdown. Finalize the
|
||||
// session first because os.Exit skips deferred cleanup.
|
||||
_ = sessionRec.Finalize(fmt.Errorf("signal: %s", sig))
|
||||
os.Exit(130)
|
||||
}
|
||||
}()
|
||||
|
||||
return func() {
|
||||
signal.Stop(sigCh)
|
||||
close(sigCh)
|
||||
<-done
|
||||
}
|
||||
}
|
||||
|
||||
const (
|
||||
|
||||
// satAmtFmt formats a satoshi value into a one line string, intended to
|
||||
|
|
@ -186,7 +230,8 @@ func printJSON(resp any) {
|
|||
fatal(err)
|
||||
}
|
||||
out.WriteString("\n")
|
||||
_, _ = out.WriteTo(os.Stdout)
|
||||
printBytes := maybeNormalizeJSON(out.Bytes())
|
||||
_, _ = os.Stdout.Write(printBytes)
|
||||
}
|
||||
|
||||
func printRespJSON(resp proto.Message) {
|
||||
|
|
@ -196,19 +241,71 @@ func printRespJSON(resp proto.Message) {
|
|||
return
|
||||
}
|
||||
|
||||
fmt.Println(string(jsonBytes))
|
||||
fmt.Println(string(maybeNormalizeJSON(jsonBytes)))
|
||||
}
|
||||
|
||||
func fatal(err error) {
|
||||
fmt.Fprintf(os.Stderr, "[loop] %v\n", err)
|
||||
if sessionRec != nil {
|
||||
if finalizeErr := sessionRec.Finalize(err); finalizeErr != nil {
|
||||
fmt.Fprintf(os.Stderr, "[loop] unable to finalize "+
|
||||
"session: %v\n", finalizeErr)
|
||||
}
|
||||
}
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
func main() {
|
||||
rootCmd := newRootCommand()
|
||||
if err := rootCmd.Run(context.Background(), os.Args); err != nil {
|
||||
var err error
|
||||
sessionRec, err = newSessionRecorder(os.Args)
|
||||
if err != nil {
|
||||
fatal(err)
|
||||
}
|
||||
|
||||
// Intercept clock and stdio if needed.
|
||||
if sessionRec != nil {
|
||||
restoreClock := hookClock(
|
||||
clock.NewTestClock(sessionRec.ClockStart()),
|
||||
)
|
||||
defer restoreClock()
|
||||
|
||||
if err := sessionRec.Start(nil, nil, nil); err != nil {
|
||||
fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
// Intercept clock calls if needed.
|
||||
var restoreTransport func()
|
||||
if sessionRec != nil {
|
||||
restoreTransport = hookGrpc(sessionRec)
|
||||
defer restoreTransport()
|
||||
}
|
||||
|
||||
rootCmd := newRootCommand()
|
||||
|
||||
ctx := context.Background()
|
||||
ctx, cancel := context.WithCancel(ctx)
|
||||
defer cancel()
|
||||
|
||||
if sessionRec != nil {
|
||||
ctx = sessionRec.InjectContext(ctx)
|
||||
}
|
||||
|
||||
if sessionRec != nil {
|
||||
signalStop := installSessionSignalHandler(cancel)
|
||||
defer signalStop()
|
||||
}
|
||||
|
||||
if err := rootCmd.Run(ctx, os.Args); err != nil {
|
||||
fatal(err)
|
||||
}
|
||||
|
||||
if sessionRec != nil {
|
||||
if err := sessionRec.Finalize(nil); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "[loop] unable to finalize "+
|
||||
"session: %v\n", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// newRootCommand constructs the CLI root command for loop.
|
||||
|
|
@ -238,9 +335,7 @@ func newRootCommand() *cli.Command {
|
|||
|
||||
// getClient establishes a SwapClient RPC connection and returns the client and
|
||||
// a cleanup handler.
|
||||
func getClient(cmd *cli.Command) (looprpc.SwapClientClient,
|
||||
func(), error) {
|
||||
|
||||
func getClient(cmd *cli.Command) (looprpc.SwapClientClient, func(), error) {
|
||||
client, _, cleanup, err := getClientWithConn(cmd)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
|
|
@ -273,6 +368,33 @@ func hookClock(c clock.Clock) func() {
|
|||
}
|
||||
}
|
||||
|
||||
// maybeNormalizeJSON rewrites JSON output to avoid the build-dependent spacing
|
||||
// introduced by google.golang.org/protobuf/internal/encoding/json (see
|
||||
// WriteName in protobuf-go-hex-display/internal/encoding/json/encode.go, which
|
||||
// uses internal/detrand.Bool). When recording or replaying sessions we ensure
|
||||
// stable output by re-encoding with the standard library.
|
||||
func maybeNormalizeJSON(raw []byte) []byte {
|
||||
if sessionRec == nil && !forceDeterministicJSON {
|
||||
return raw
|
||||
}
|
||||
|
||||
var parsed any
|
||||
if err := json.Unmarshal(raw, &parsed); err != nil {
|
||||
return raw
|
||||
}
|
||||
|
||||
normalized, err := json.MarshalIndent(parsed, "", " ")
|
||||
if err != nil {
|
||||
return raw
|
||||
}
|
||||
|
||||
if len(raw) > 0 && raw[len(raw)-1] == '\n' {
|
||||
normalized = append(normalized, '\n')
|
||||
}
|
||||
|
||||
return normalized
|
||||
}
|
||||
|
||||
func getMaxRoutingFee(amt btcutil.Amount) btcutil.Amount {
|
||||
return swap.CalcFee(amt, maxRoutingFeeBase, maxRoutingFeeRate)
|
||||
}
|
||||
|
|
|
|||
696
cmd/loop/session_recorder.go
Normal file
696
cmd/loop/session_recorder.go
Normal file
|
|
@ -0,0 +1,696 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/lightninglabs/loop"
|
||||
"github.com/urfave/cli/v3"
|
||||
statuspb "google.golang.org/genproto/googleapis/rpc/status"
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/metadata"
|
||||
grpcstatus "google.golang.org/grpc/status"
|
||||
"google.golang.org/protobuf/encoding/protojson"
|
||||
"google.golang.org/protobuf/proto"
|
||||
)
|
||||
|
||||
const (
|
||||
sessionEnvVar = "LOOP_SESSION_RECORD"
|
||||
sessionDefaultDir = "cmd/loop/testdata/sessions"
|
||||
sessionFileExt = ".json"
|
||||
defaultSessionSlug = "session"
|
||||
)
|
||||
|
||||
// Event identifiers in recorded sessions.
|
||||
const (
|
||||
eventStdout = "stdout"
|
||||
eventStderr = "stderr"
|
||||
eventStdin = "stdin"
|
||||
eventGrpc = "grpc"
|
||||
eventExit = "exit"
|
||||
eventSignal = "signal"
|
||||
)
|
||||
|
||||
// grpcMarshalOptions is marshalling options to encode gRPC messages in recorded
|
||||
// sessions.
|
||||
var grpcMarshalOptions = protojson.MarshalOptions{
|
||||
UseProtoNames: true,
|
||||
EmitUnpopulated: true,
|
||||
}
|
||||
|
||||
// sessionRecorder captures CLI IO and gRPC traffic for replay.
|
||||
type sessionRecorder struct {
|
||||
mu sync.Mutex
|
||||
started time.Time
|
||||
filePath string
|
||||
slug string
|
||||
|
||||
metadata sessionMetadata
|
||||
events []sessionEvent
|
||||
eventErr error
|
||||
|
||||
finalizeOnce sync.Once
|
||||
|
||||
hooksMu sync.Mutex
|
||||
hooksStarted bool
|
||||
stdoutUnhook func() error
|
||||
stderrUnhook func() error
|
||||
stdinUnhook func() error
|
||||
}
|
||||
|
||||
// sessionFile is a single recorded CLI session in JSON format. They are
|
||||
// stored inside sessionDefaultDir.
|
||||
type sessionFile struct {
|
||||
Metadata sessionMetadata `json:"metadata"`
|
||||
Events []sessionEvent `json:"events"`
|
||||
}
|
||||
|
||||
// sessionMetadata stores static session details and runtime metadata.
|
||||
type sessionMetadata struct {
|
||||
Args []string `json:"args"`
|
||||
Env map[string]string `json:"env"`
|
||||
Version string `json:"version"`
|
||||
ClockStartUnix int64 `json:"clock_start_unix"`
|
||||
RunError *string `json:"run_error,omitempty"`
|
||||
Duration *time.Duration `json:"duration,omitempty"`
|
||||
}
|
||||
|
||||
// sessionEvent records a single timestamped payload entry.
|
||||
type sessionEvent struct {
|
||||
TimeMS int64 `json:"time_ms"`
|
||||
Kind string `json:"kind"`
|
||||
Data json.RawMessage `json:"data"`
|
||||
}
|
||||
|
||||
// textPayload records stdout/stderr text chunks as newline-preserving lines.
|
||||
type textPayload struct {
|
||||
Lines []string `json:"lines"`
|
||||
}
|
||||
|
||||
// newTextPayload splits text into newline-preserving lines for fixture output.
|
||||
func newTextPayload(text string) textPayload {
|
||||
return textPayload{
|
||||
Lines: splitTextLines(text),
|
||||
}
|
||||
}
|
||||
|
||||
// text joins a recorded text payload back into the original output.
|
||||
func (p textPayload) text() string {
|
||||
return joinTextLines(p.Lines)
|
||||
}
|
||||
|
||||
// stdinPayload records stdin chunks.
|
||||
type stdinPayload struct {
|
||||
Text string `json:"text"`
|
||||
}
|
||||
|
||||
// splitTextLines preserves line endings while splitting text for fixture
|
||||
// storage.
|
||||
func splitTextLines(text string) []string {
|
||||
if text == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
lines := strings.SplitAfter(text, "\n")
|
||||
if lines[len(lines)-1] == "" {
|
||||
lines = lines[:len(lines)-1]
|
||||
}
|
||||
|
||||
return lines
|
||||
}
|
||||
|
||||
// joinTextLines reconstructs text stored as newline-preserving lines.
|
||||
func joinTextLines(lines []string) string {
|
||||
return strings.Join(lines, "")
|
||||
}
|
||||
|
||||
// grpcPayload records a gRPC message or error event.
|
||||
type grpcPayload struct {
|
||||
Method string `json:"method"`
|
||||
Event string `json:"event"`
|
||||
MessageType string `json:"message_type,omitempty"`
|
||||
Payload json.RawMessage `json:"payload,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
Status *statuspb.Status `json:"status,omitempty"`
|
||||
}
|
||||
|
||||
// exitPayload records the final run error, if any.
|
||||
type exitPayload struct {
|
||||
RunError *string `json:"run_error,omitempty"`
|
||||
}
|
||||
|
||||
// signalPayload records handled signals.
|
||||
type signalPayload struct {
|
||||
Signal string `json:"signal"`
|
||||
}
|
||||
|
||||
// newSessionRecorder creates a recorder when session recording is enabled.
|
||||
func newSessionRecorder(args []string) (*sessionRecorder, error) {
|
||||
// Session recording is disabled unless the env var is set.
|
||||
envValue, ok := os.LookupEnv(sessionEnvVar)
|
||||
if !ok {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// Allow explicit disable by setting the env var to false.
|
||||
enabled, err := strconv.ParseBool(envValue)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid %s value %q", sessionEnvVar,
|
||||
envValue)
|
||||
}
|
||||
if !enabled {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// Initialize the recorder before collecting metadata.
|
||||
recorder := &sessionRecorder{
|
||||
started: time.Now(),
|
||||
}
|
||||
|
||||
// Derive the slug before resolving the output file path.
|
||||
recorder.slug = deriveSessionSlug(args)
|
||||
|
||||
// Capture metadata that remains stable for the session.
|
||||
metadata := sessionMetadata{
|
||||
Args: append([]string(nil), args...),
|
||||
Env: collectSessionEnv(),
|
||||
Version: loop.RichVersion(),
|
||||
ClockStartUnix: recorder.started.Unix(),
|
||||
}
|
||||
recorder.metadata = metadata
|
||||
|
||||
// Resolve the session file location.
|
||||
baseDir, fileName, err := recorder.resolveFilePath()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
recorder.filePath = filepath.Join(baseDir, fileName)
|
||||
|
||||
return recorder, nil
|
||||
}
|
||||
|
||||
// collectSessionEnv extracts the environment variables recorded in sessions.
|
||||
func collectSessionEnv() map[string]string {
|
||||
env := make(map[string]string)
|
||||
|
||||
// Record only LOOPCLI_ variables, excluding the recording toggle.
|
||||
for _, kv := range os.Environ() {
|
||||
parts := strings.SplitN(kv, "=", 2)
|
||||
if len(parts) != 2 {
|
||||
continue
|
||||
}
|
||||
key := parts[0]
|
||||
value := parts[1]
|
||||
if key == sessionEnvVar {
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(key, "LOOPCLI_") {
|
||||
env[key] = value
|
||||
}
|
||||
}
|
||||
|
||||
return env
|
||||
}
|
||||
|
||||
// resolveFilePath chooses the output directory and filename.
|
||||
func (r *sessionRecorder) resolveFilePath() (string, string, error) {
|
||||
if err := ensureSessionBaseDir(sessionDefaultDir); err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
counter, err := nextSessionCounter(sessionDefaultDir)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
slug := r.slug
|
||||
if slug == "" {
|
||||
slug = defaultSessionSlug
|
||||
}
|
||||
|
||||
name := fmt.Sprintf("%02d_%s%s", counter, slug, sessionFileExt)
|
||||
|
||||
return sessionDefaultDir, name, nil
|
||||
}
|
||||
|
||||
// ensureSessionBaseDir verifies that recording is running from the repo root.
|
||||
func ensureSessionBaseDir(baseDir string) error {
|
||||
info, err := os.Stat(baseDir)
|
||||
if errors.Is(err, fs.ErrNotExist) {
|
||||
return fmt.Errorf("%s does not exist; run session recording "+
|
||||
"from the repository root", baseDir)
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !info.IsDir() {
|
||||
return fmt.Errorf("%s is not a directory", baseDir)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// logEvent records a new event with the elapsed timestamp.
|
||||
func (r *sessionRecorder) logEvent(kind string, payload any) {
|
||||
data, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
r.mu.Lock()
|
||||
if r.eventErr == nil {
|
||||
r.eventErr = fmt.Errorf("marshal session %s event: %w",
|
||||
kind, err)
|
||||
}
|
||||
r.mu.Unlock()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
event := sessionEvent{
|
||||
TimeMS: time.Since(r.started).Milliseconds(),
|
||||
Kind: kind,
|
||||
Data: data,
|
||||
}
|
||||
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
|
||||
r.events = append(r.events, event)
|
||||
}
|
||||
|
||||
// Start attaches stdin/stdout/stderr hooks for session recording.
|
||||
func (r *sessionRecorder) Start(stdinSource io.Reader,
|
||||
stdoutForward, stderrForward io.Writer) error {
|
||||
|
||||
r.hooksMu.Lock()
|
||||
defer r.hooksMu.Unlock()
|
||||
|
||||
if r.hooksStarted {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Capture stdout and stderr first, then stdin.
|
||||
origStdout := os.Stdout
|
||||
if stdoutForward == nil {
|
||||
stdoutForward = origStdout
|
||||
}
|
||||
outHook, err := hookStdout(origStdout, stdoutForward, func(p []byte) {
|
||||
r.logEvent(eventStdout, newTextPayload(string(p)))
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
origStderr := os.Stderr
|
||||
if stderrForward == nil {
|
||||
stderrForward = origStderr
|
||||
}
|
||||
errHook, err := hookStderr(origStderr, stderrForward, func(p []byte) {
|
||||
r.logEvent(eventStderr, newTextPayload(string(p)))
|
||||
})
|
||||
if err != nil {
|
||||
_ = outHook()
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
origStdin := os.Stdin
|
||||
if stdinSource == nil {
|
||||
stdinSource = origStdin
|
||||
}
|
||||
stdinHook, err := hookStdin(origStdin, stdinSource, func(p []byte) {
|
||||
r.logEvent(eventStdin, stdinPayload{Text: string(p)})
|
||||
})
|
||||
if err != nil {
|
||||
_ = errHook()
|
||||
_ = outHook()
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
r.stdoutUnhook = outHook
|
||||
r.stderrUnhook = errHook
|
||||
r.stdinUnhook = stdinHook
|
||||
r.hooksStarted = true
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// stopHooks detaches any active IO hooks.
|
||||
func (r *sessionRecorder) stopHooks() error {
|
||||
r.hooksMu.Lock()
|
||||
defer r.hooksMu.Unlock()
|
||||
|
||||
var firstErr error
|
||||
if r.stdoutUnhook != nil {
|
||||
if err := r.stdoutUnhook(); err != nil && firstErr == nil {
|
||||
firstErr = err
|
||||
}
|
||||
r.stdoutUnhook = nil
|
||||
}
|
||||
if r.stderrUnhook != nil {
|
||||
if err := r.stderrUnhook(); err != nil && firstErr == nil {
|
||||
firstErr = err
|
||||
}
|
||||
r.stderrUnhook = nil
|
||||
}
|
||||
if r.stdinUnhook != nil {
|
||||
if err := r.stdinUnhook(); err != nil && firstErr == nil {
|
||||
firstErr = err
|
||||
}
|
||||
r.stdinUnhook = nil
|
||||
}
|
||||
r.hooksStarted = false
|
||||
|
||||
return firstErr
|
||||
}
|
||||
|
||||
// logExit records the final outcome and duration.
|
||||
func (r *sessionRecorder) logExit(runErr error) {
|
||||
var payload exitPayload
|
||||
if runErr != nil {
|
||||
msg := runErr.Error()
|
||||
payload.RunError = &msg
|
||||
}
|
||||
|
||||
// Store the exit event first for the event stream.
|
||||
r.logEvent(eventExit, payload)
|
||||
|
||||
// Update metadata with the final run state.
|
||||
duration := time.Since(r.started)
|
||||
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
|
||||
if runErr != nil {
|
||||
msg := runErr.Error()
|
||||
r.metadata.RunError = &msg
|
||||
} else {
|
||||
r.metadata.RunError = nil
|
||||
}
|
||||
r.metadata.Duration = &duration
|
||||
}
|
||||
|
||||
// finalize writes the recorded session to disk once.
|
||||
func (r *sessionRecorder) finalize(runErr error) error {
|
||||
var finalizeErr error
|
||||
r.finalizeOnce.Do(func() {
|
||||
hookErr := r.stopHooks()
|
||||
|
||||
r.logExit(runErr)
|
||||
|
||||
r.mu.Lock()
|
||||
metadata := r.metadata
|
||||
events := append([]sessionEvent(nil), r.events...)
|
||||
eventErr := r.eventErr
|
||||
r.mu.Unlock()
|
||||
|
||||
fileContent := sessionFile{
|
||||
Metadata: metadata,
|
||||
Events: events,
|
||||
}
|
||||
|
||||
err := os.MkdirAll(filepath.Dir(r.filePath), 0o755)
|
||||
if err != nil {
|
||||
finalizeErr = errors.Join(err, eventErr, hookErr)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
file, err := os.Create(r.filePath)
|
||||
if err != nil {
|
||||
finalizeErr = errors.Join(err, eventErr, hookErr)
|
||||
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
encoder := json.NewEncoder(file)
|
||||
encoder.SetIndent("", " ")
|
||||
if err := encoder.Encode(fileContent); err != nil {
|
||||
finalizeErr = errors.Join(err, eventErr, hookErr)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
finalizeErr = errors.Join(eventErr, hookErr)
|
||||
})
|
||||
|
||||
return finalizeErr
|
||||
}
|
||||
|
||||
// Finalize records the exit event and flushes the session to disk.
|
||||
func (r *sessionRecorder) Finalize(runErr error) error {
|
||||
return r.finalize(runErr)
|
||||
}
|
||||
|
||||
// ClockStart returns the fixed session clock used while recording CLI actions.
|
||||
func (r *sessionRecorder) ClockStart() time.Time {
|
||||
return time.Unix(r.metadata.ClockStartUnix, 0)
|
||||
}
|
||||
|
||||
// Dial uses the direct gRPC connection for recording.
|
||||
func (r *sessionRecorder) Dial(cmd *cli.Command) (daemonConn, func(), error) {
|
||||
return dialDirectConn(cmd)
|
||||
}
|
||||
|
||||
// UnaryInterceptor captures unary RPCs for session playback.
|
||||
func (r *sessionRecorder) UnaryInterceptor() grpc.UnaryClientInterceptor {
|
||||
return func(ctx context.Context, method string, req, reply any,
|
||||
cc *grpc.ClientConn, invoker grpc.UnaryInvoker,
|
||||
opts ...grpc.CallOption) error {
|
||||
|
||||
r.logGRPCMessage(method, "request", req, nil)
|
||||
|
||||
err := invoker(ctx, method, req, reply, cc, opts...)
|
||||
if err != nil {
|
||||
r.logGRPCMessage(method, "error", nil, err)
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
r.logGRPCMessage(method, "response", reply, nil)
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// StreamInterceptor captures stream RPCs for session playback.
|
||||
func (r *sessionRecorder) StreamInterceptor() grpc.StreamClientInterceptor {
|
||||
return func(ctx context.Context, desc *grpc.StreamDesc,
|
||||
cc *grpc.ClientConn, method string, streamer grpc.Streamer,
|
||||
opts ...grpc.CallOption) (grpc.ClientStream, error) {
|
||||
|
||||
clientStream, err := streamer(ctx, desc, cc, method, opts...)
|
||||
if err != nil {
|
||||
r.logGRPCMessage(method, "error", nil, err)
|
||||
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &recordingClientStream{
|
||||
ClientStream: clientStream,
|
||||
recorder: r,
|
||||
method: method,
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
|
||||
// recordingClientStream wraps a gRPC stream and logs message events.
|
||||
type recordingClientStream struct {
|
||||
grpc.ClientStream
|
||||
|
||||
recorder *sessionRecorder
|
||||
method string
|
||||
}
|
||||
|
||||
// SendMsg records the outgoing stream message.
|
||||
func (s *recordingClientStream) SendMsg(m any) error {
|
||||
s.recorder.logGRPCMessage(s.method, "send", m, nil)
|
||||
err := s.ClientStream.SendMsg(m)
|
||||
if err != nil {
|
||||
s.recorder.logGRPCMessage(s.method, "error", nil, err)
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
// RecvMsg records the incoming stream message.
|
||||
func (s *recordingClientStream) RecvMsg(m any) error {
|
||||
err := s.ClientStream.RecvMsg(m)
|
||||
if err != nil {
|
||||
s.recorder.logGRPCMessage(s.method, "error", nil, err)
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
s.recorder.logGRPCMessage(s.method, "recv", m, nil)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// logGRPCMessage captures gRPC request/response data in the event stream.
|
||||
func (r *sessionRecorder) logGRPCMessage(method, event string, msg any,
|
||||
receptionErr error) {
|
||||
|
||||
payload := grpcPayload{Method: method, Event: event}
|
||||
|
||||
if receptionErr != nil {
|
||||
payload.Error = receptionErr.Error()
|
||||
if grpcStatus, ok := grpcstatus.FromError(receptionErr); ok {
|
||||
payload.Status = grpcStatus.Proto()
|
||||
}
|
||||
r.logEvent(eventGrpc, payload)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if msg != nil {
|
||||
if protoMsg, ok := msg.(proto.Message); ok {
|
||||
payload.MessageType = string(
|
||||
proto.MessageName(protoMsg),
|
||||
)
|
||||
data, err := grpcMarshalOptions.Marshal(protoMsg)
|
||||
if err == nil {
|
||||
payload.Payload = data
|
||||
}
|
||||
} else {
|
||||
data, err := json.Marshal(msg)
|
||||
if err == nil {
|
||||
payload.Payload = data
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
r.logEvent(eventGrpc, payload)
|
||||
}
|
||||
|
||||
// LogSignal records an incoming signal event.
|
||||
func (r *sessionRecorder) LogSignal(sig os.Signal) {
|
||||
r.logEvent(eventSignal, signalPayload{Signal: sig.String()})
|
||||
}
|
||||
|
||||
// InjectContext tags the outgoing context with the session name.
|
||||
func (r *sessionRecorder) InjectContext(ctx context.Context) context.Context {
|
||||
return metadata.AppendToOutgoingContext(
|
||||
ctx, "loop-session", filepath.Base(r.filePath),
|
||||
)
|
||||
}
|
||||
|
||||
// deriveSessionSlug builds a stable slug from the command arguments.
|
||||
func deriveSessionSlug(args []string) string {
|
||||
if len(args) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
base := filepath.Base(args[0])
|
||||
tokens := []string{base}
|
||||
|
||||
for _, arg := range args[1:] {
|
||||
if strings.HasPrefix(arg, "-") {
|
||||
break
|
||||
}
|
||||
if arg == "" {
|
||||
continue
|
||||
}
|
||||
tokens = append(tokens, arg)
|
||||
}
|
||||
|
||||
return sanitizeSlug(strings.Join(tokens, "-"))
|
||||
}
|
||||
|
||||
// sanitizeSlug normalizes a session slug to a safe filename.
|
||||
func sanitizeSlug(value string) string {
|
||||
value = strings.ToLower(value)
|
||||
var builder strings.Builder
|
||||
lastDash := false
|
||||
for _, r := range value {
|
||||
if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') {
|
||||
builder.WriteRune(r)
|
||||
lastDash = false
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
if !lastDash {
|
||||
builder.WriteRune('-')
|
||||
lastDash = true
|
||||
}
|
||||
}
|
||||
|
||||
slug := strings.Trim(builder.String(), "-")
|
||||
if slug == "" {
|
||||
return defaultSessionSlug
|
||||
}
|
||||
|
||||
return slug
|
||||
}
|
||||
|
||||
// nextSessionCounter finds the next available session number.
|
||||
func nextSessionCounter(baseDir string) (int, error) {
|
||||
maxCounter := 0
|
||||
entries, err := os.ReadDir(baseDir)
|
||||
if errors.Is(err, fs.ErrNotExist) {
|
||||
return 1, nil
|
||||
}
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() {
|
||||
continue
|
||||
}
|
||||
if filepath.Ext(entry.Name()) != sessionFileExt {
|
||||
continue
|
||||
}
|
||||
|
||||
counter, ok := parseSessionCounter(entry.Name())
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if counter > maxCounter {
|
||||
maxCounter = counter
|
||||
}
|
||||
}
|
||||
|
||||
return maxCounter + 1, nil
|
||||
}
|
||||
|
||||
// parseSessionCounter extracts the numeric prefix from a session filename.
|
||||
func parseSessionCounter(name string) (int, bool) {
|
||||
base := strings.TrimSuffix(name, filepath.Ext(name))
|
||||
if base == "" {
|
||||
return 0, false
|
||||
}
|
||||
|
||||
parts := strings.SplitN(base, "_", 2)
|
||||
if parts[0] == "" {
|
||||
return 0, false
|
||||
}
|
||||
|
||||
for _, r := range parts[0] {
|
||||
if r < '0' || r > '9' {
|
||||
return 0, false
|
||||
}
|
||||
}
|
||||
|
||||
value, err := strconv.Atoi(parts[0])
|
||||
if err != nil {
|
||||
return 0, false
|
||||
}
|
||||
|
||||
if value < 0 {
|
||||
return 0, false
|
||||
}
|
||||
|
||||
return value, true
|
||||
}
|
||||
343
cmd/loop/session_recorder_test.go
Normal file
343
cmd/loop/session_recorder_test.go
Normal file
|
|
@ -0,0 +1,343 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
"google.golang.org/grpc/codes"
|
||||
grpcstatus "google.golang.org/grpc/status"
|
||||
)
|
||||
|
||||
const (
|
||||
testLoopArg = "loop"
|
||||
testOutArg = "out"
|
||||
testLoopOutSlug = "loop-out"
|
||||
testVersion = "test-version"
|
||||
testClockStart = int64(1769407086)
|
||||
)
|
||||
|
||||
// TestDeriveSessionSlug verifies slug derivation from CLI arguments.
|
||||
func TestDeriveSessionSlug(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "empty_args",
|
||||
want: "",
|
||||
},
|
||||
{
|
||||
name: "binary_only",
|
||||
args: []string{"/usr/local/bin/loop"},
|
||||
want: testLoopArg,
|
||||
},
|
||||
{
|
||||
name: "with_subcommand",
|
||||
args: []string{testLoopArg, testOutArg},
|
||||
want: testLoopOutSlug,
|
||||
},
|
||||
{
|
||||
name: "stops_at_flag",
|
||||
args: []string{testLoopArg, testOutArg, "--network", "regtest"},
|
||||
want: testLoopOutSlug,
|
||||
},
|
||||
{
|
||||
name: "skips_empty_args",
|
||||
args: []string{testLoopArg, "", "quote", testOutArg},
|
||||
want: "loop-quote-out",
|
||||
},
|
||||
{
|
||||
name: "sanitizes_tokens",
|
||||
args: []string{testLoopArg, "Quote", "Out"},
|
||||
want: "loop-quote-out",
|
||||
},
|
||||
{
|
||||
name: "sanitizes_path_base",
|
||||
args: []string{"/tmp/loop-cli", testOutArg},
|
||||
want: "loop-cli-out",
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
slug := deriveSessionSlug(test.args)
|
||||
require.Equal(t, test.want, slug)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestSanitizeSlug verifies slug normalization behavior.
|
||||
func TestSanitizeSlug(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "already_clean",
|
||||
input: testLoopOutSlug,
|
||||
want: testLoopOutSlug,
|
||||
},
|
||||
{
|
||||
name: "upper_and_spaces",
|
||||
input: "Loop Out",
|
||||
want: testLoopOutSlug,
|
||||
},
|
||||
{
|
||||
name: "symbols_collapsed",
|
||||
input: "loop@@@out",
|
||||
want: testLoopOutSlug,
|
||||
},
|
||||
{
|
||||
name: "trims_dashes",
|
||||
input: "--loop-out--",
|
||||
want: testLoopOutSlug,
|
||||
},
|
||||
{
|
||||
name: "empty_to_default",
|
||||
input: "!!!",
|
||||
want: defaultSessionSlug,
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
slug := sanitizeSlug(test.input)
|
||||
require.Equal(t, test.want, slug)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestParseSessionCounter verifies session counter parsing.
|
||||
func TestParseSessionCounter(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
want int
|
||||
ok bool
|
||||
}{
|
||||
{
|
||||
name: "with_suffix",
|
||||
input: "01_loop-out.json",
|
||||
want: 1,
|
||||
ok: true,
|
||||
},
|
||||
{
|
||||
name: "with_extra_underscores",
|
||||
input: "12_loop_in.json",
|
||||
want: 12,
|
||||
ok: true,
|
||||
},
|
||||
{
|
||||
name: "no_suffix",
|
||||
input: "99",
|
||||
want: 99,
|
||||
ok: true,
|
||||
},
|
||||
{
|
||||
name: "no_extension",
|
||||
input: "7_loop-out",
|
||||
want: 7,
|
||||
ok: true,
|
||||
},
|
||||
{
|
||||
name: "empty_name",
|
||||
input: "",
|
||||
ok: false,
|
||||
},
|
||||
{
|
||||
name: "missing_prefix",
|
||||
input: "_loop.json",
|
||||
ok: false,
|
||||
},
|
||||
{
|
||||
name: "non_numeric_prefix",
|
||||
input: "loop.json",
|
||||
ok: false,
|
||||
},
|
||||
{
|
||||
name: "mixed_prefix",
|
||||
input: "1a_loop.json",
|
||||
ok: false,
|
||||
},
|
||||
{
|
||||
name: "negative_prefix",
|
||||
input: "-1_loop.json",
|
||||
ok: false,
|
||||
},
|
||||
{
|
||||
name: "plus_prefix",
|
||||
input: "+1_loop.json",
|
||||
ok: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
value, ok := parseSessionCounter(test.input)
|
||||
require.Equal(t, test.ok, ok)
|
||||
if test.ok {
|
||||
require.Equal(t, test.want, value)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestSessionFinalizeWritesFileWhenHookRestoreFails verifies that session
|
||||
// data is still written to disk when hook restoration returns an error.
|
||||
func TestSessionFinalizeWritesFileWhenHookRestoreFails(t *testing.T) {
|
||||
recorder := &sessionRecorder{
|
||||
started: time.Now().Add(-time.Second),
|
||||
filePath: filepath.Join(t.TempDir(), "session.json"),
|
||||
metadata: sessionMetadata{
|
||||
Args: []string{testLoopArg, testOutArg},
|
||||
Env: map[string]string{},
|
||||
Version: testVersion,
|
||||
ClockStartUnix: testClockStart,
|
||||
},
|
||||
hooksStarted: true,
|
||||
stdoutUnhook: func() error {
|
||||
return errors.New("stdout restore failed")
|
||||
},
|
||||
}
|
||||
|
||||
err := recorder.Finalize(nil)
|
||||
require.EqualError(t, err, "stdout restore failed")
|
||||
|
||||
blob, err := os.ReadFile(recorder.filePath)
|
||||
require.NoError(t, err)
|
||||
|
||||
var data sessionFile
|
||||
require.NoError(t, json.Unmarshal(blob, &data))
|
||||
require.Equal(t, recorder.metadata.Args, data.Metadata.Args)
|
||||
require.Equal(t, recorder.metadata.Version, data.Metadata.Version)
|
||||
require.NotNil(t, data.Metadata.Duration)
|
||||
require.Nil(t, data.Metadata.RunError)
|
||||
require.Len(t, data.Events, 1)
|
||||
require.Equal(t, eventExit, data.Events[0].Kind)
|
||||
}
|
||||
|
||||
// TestSessionFinalizeReportsWriteAndHookErrors verifies that a write failure
|
||||
// and hook restoration failure are both returned.
|
||||
func TestSessionFinalizeReportsWriteAndHookErrors(t *testing.T) {
|
||||
recorder := &sessionRecorder{
|
||||
started: time.Now().Add(-time.Second),
|
||||
filePath: t.TempDir(),
|
||||
metadata: sessionMetadata{
|
||||
Args: []string{testLoopArg, testOutArg},
|
||||
Env: map[string]string{},
|
||||
Version: testVersion,
|
||||
ClockStartUnix: testClockStart,
|
||||
},
|
||||
hooksStarted: true,
|
||||
stdoutUnhook: func() error {
|
||||
return errors.New("stdout restore failed")
|
||||
},
|
||||
}
|
||||
|
||||
err := recorder.Finalize(nil)
|
||||
require.ErrorContains(t, err, "is a directory")
|
||||
require.ErrorContains(t, err, "stdout restore failed")
|
||||
}
|
||||
|
||||
// TestSessionFinalizeReportsDeferredMarshalError verifies that marshal
|
||||
// failures encountered while recording are reported from Finalize while the
|
||||
// session file is still written to disk.
|
||||
func TestSessionFinalizeReportsDeferredMarshalError(t *testing.T) {
|
||||
recorder := &sessionRecorder{
|
||||
started: time.Now().Add(-time.Second),
|
||||
filePath: filepath.Join(t.TempDir(), "session.json"),
|
||||
metadata: sessionMetadata{
|
||||
Args: []string{testLoopArg, testOutArg},
|
||||
Env: map[string]string{},
|
||||
Version: testVersion,
|
||||
ClockStartUnix: testClockStart,
|
||||
},
|
||||
}
|
||||
|
||||
// This logEvent will fail, because chan int is not JSON marshallable.
|
||||
recorder.logEvent(eventStdout, make(chan int))
|
||||
|
||||
err := recorder.Finalize(nil)
|
||||
require.ErrorContains(t, err, "marshal session stdout event")
|
||||
require.ErrorContains(t, err, "unsupported type: chan int")
|
||||
|
||||
blob, err := os.ReadFile(recorder.filePath)
|
||||
require.NoError(t, err)
|
||||
|
||||
var data sessionFile
|
||||
require.NoError(t, json.Unmarshal(blob, &data))
|
||||
require.Equal(t, recorder.metadata.Args, data.Metadata.Args)
|
||||
require.Equal(t, recorder.metadata.Version, data.Metadata.Version)
|
||||
require.NotNil(t, data.Metadata.Duration)
|
||||
require.Nil(t, data.Metadata.RunError)
|
||||
require.Len(t, data.Events, 1)
|
||||
require.Equal(t, eventExit, data.Events[0].Kind)
|
||||
}
|
||||
|
||||
// TestNewSessionRecorderRequiresRepoRoot verifies that recording fails clearly
|
||||
// when the session fixture directory is not available from the current working
|
||||
// directory.
|
||||
func TestNewSessionRecorderRequiresRepoRoot(t *testing.T) {
|
||||
t.Chdir(t.TempDir())
|
||||
t.Setenv(sessionEnvVar, "true")
|
||||
|
||||
recorder, err := newSessionRecorder([]string{testLoopArg, testOutArg})
|
||||
require.Nil(t, recorder)
|
||||
require.EqualError(t, err, "cmd/loop/testdata/sessions does not "+
|
||||
"exist; run session recording from the repository root")
|
||||
}
|
||||
|
||||
// TestNewSessionRecorderCapturesClockStart verifies that new recordings store
|
||||
// the clock start used for deterministic replay.
|
||||
func TestNewSessionRecorderCapturesClockStart(t *testing.T) {
|
||||
repoRoot := t.TempDir()
|
||||
require.NoError(
|
||||
t, os.MkdirAll(filepath.Join(repoRoot, sessionDefaultDir), 0o755),
|
||||
)
|
||||
t.Chdir(repoRoot)
|
||||
t.Setenv(sessionEnvVar, "true")
|
||||
|
||||
before := time.Now().Unix()
|
||||
recorder, err := newSessionRecorder([]string{testLoopArg, testOutArg})
|
||||
after := time.Now().Unix()
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, recorder)
|
||||
require.GreaterOrEqual(t, recorder.metadata.ClockStartUnix, before)
|
||||
require.LessOrEqual(t, recorder.metadata.ClockStartUnix, after)
|
||||
require.Equal(
|
||||
t, time.Unix(recorder.metadata.ClockStartUnix, 0),
|
||||
recorder.ClockStart(),
|
||||
)
|
||||
}
|
||||
|
||||
// TestLogGRPCMessageRecordsStatus verifies that gRPC status errors retain
|
||||
// their structured status details in recorded sessions.
|
||||
func TestLogGRPCMessageRecordsStatus(t *testing.T) {
|
||||
recorder := &sessionRecorder{
|
||||
started: time.Now(),
|
||||
}
|
||||
|
||||
statusErr := grpcstatus.Error(
|
||||
codes.FailedPrecondition, "no rules set",
|
||||
)
|
||||
recorder.logGRPCMessage(
|
||||
"/looprpc.SwapClient/SuggestSwaps", "error", nil, statusErr,
|
||||
)
|
||||
|
||||
require.Len(t, recorder.events, 1)
|
||||
require.Equal(t, eventGrpc, recorder.events[0].Kind)
|
||||
|
||||
var payload grpcPayload
|
||||
require.NoError(t, json.Unmarshal(recorder.events[0].Data, &payload))
|
||||
require.Equal(t, statusErr.Error(), payload.Error)
|
||||
require.NotNil(t, payload.Status)
|
||||
require.Equal(t, int32(codes.FailedPrecondition), payload.Status.Code)
|
||||
require.Equal(t, "no rules set", payload.Status.Message)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue