cmd/loop: add session recorder plumbing

This commit is contained in:
Boris Nagaev 2026-02-04 19:01:44 -05:00
parent c3e102527a
commit 137097915c
No known key found for this signature in database
3 changed files with 1168 additions and 7 deletions

View file

@ -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)
}

View 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
}

View 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)
}