Skip to content
Merged
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
64 changes: 37 additions & 27 deletions loadtest/runner.go
Original file line number Diff line number Diff line change
Expand Up @@ -387,36 +387,44 @@ func (r *Runner) Run(ctx context.Context) error {
signal.Notify(sigCh, os.Interrupt)
defer signal.Stop(sigCh)

// Always print a light summary of whatever samples were collected,
// regardless of how Run exits (normal, interrupt, timeout, mainLoop error).
// endTime is captured below, before postLoadTest, so the TPS denominator
// reflects load-generation time, not post-test cleanup (refunds, detailed
// summary, etc.).
var endTime time.Time
defer func() {
if endTime.IsZero() {
endTime = time.Now()
}
results := r.GetResults()
if len(results) > 0 {
LightSummary(results, results[0].RequestTime, endTime, r.rl)
}
}()

errCh := make(chan error, 1)
loadTestCtx, cancel := context.WithCancel(ctx)
defer cancel()

// The goroutine must always write to errCh exactly once so the drain
// below can never deadlock. Don't gate on loadTestCtx.Done() — mainLoop
// itself respects ctx and returns promptly when cancelled.
go func() {
select {
case <-loadTestCtx.Done():
return
default:
errCh <- r.mainLoop(loadTestCtx)
}
errCh <- r.mainLoop(loadTestCtx)
}()

timedOut := false
interrupted := false
var mainLoopErr error
mainLoopDrained := false

// Wait for completion or interruption
select {
case <-overallTimer.C:
log.Info().Msg("Time's up")
timedOut = true
cancel()
case <-sigCh:
log.Info().Msg("Interrupted, stopping load test")
interrupted = true
cancel()
if r.preconfTracker != nil {
r.preconfTracker.Stats()
}
if r.cfg.ShouldProduceSummary {
finalBlock, err := r.client.BlockNumber(ctx)
if err != nil {
Expand All @@ -427,29 +435,38 @@ func (r *Runner) Run(ctx context.Context) error {
}
case err := <-errCh:
mainLoopErr = err
mainLoopDrained = true
}

if timedOut || interrupted {
// Drain mainLoop result if we exited the select via timeout or interrupt.
// The spawned goroutine always writes exactly once to errCh, so this is safe.
if !mainLoopDrained {
mainLoopErr = <-errCh
}
if mainLoopErr != nil {
log.Fatal().Err(mainLoopErr).Msg("Received critical error while running load test")
log.Error().Err(mainLoopErr).Msg("Load test main loop returned an error")
}

if timedOut {
log.Info().Msg("Finished")
return nil
}
// Capture endTime before postLoadTest so LightSummary's TPS reflects
// load-generation duration, not post-test RPC work.
endTime = time.Now()

// Post-load-test operations use the original context (not the cancelled loadTestCtx)
// to ensure summary/refund RPCs can complete successfully after SIGINT
// to ensure summary/refund RPCs can complete successfully after SIGINT.
r.postLoadTest(ctx)

log.Info().Msg("Finished")

// Propagate genuine mainLoop errors as a non-zero exit code via Cobra.
// Context cancellation from SIGINT/timeout is expected and not an error.
if mainLoopErr != nil && !errors.Is(mainLoopErr, context.Canceled) {
return mainLoopErr
}
return nil
}

// postLoadTest handles post-load-test operations like summary and fund refunding.
// Note: LightSummary is printed via a deferred call in Run, not here.
func (r *Runner) postLoadTest(ctx context.Context) {
cfg := r.cfg
results := r.GetResults()
Expand All @@ -459,13 +476,6 @@ func (r *Runner) postLoadTest(ctx context.Context) {
r.preconfTracker.Stats()
}

// Always output a light summary if we have results
if len(results) > 0 {
startTime := results[0].RequestTime
endTime := time.Now()
LightSummary(results, startTime, endTime, r.rl)
}

// Skip detailed summary and refunds in fire-and-forget or call-only modes.
// In these modes, transactions aren't tracked or no transactions are sent,
// making detailed summaries misleading and refunds unnecessary.
Expand Down