From 309e549c61df39d55c3c3925b39e9bd37634bc20 Mon Sep 17 00:00:00 2001 From: Ji Hwan Date: Tue, 19 May 2026 15:03:22 +0900 Subject: [PATCH 1/2] refactor(loadtest): always write mainLoop result to errCh The previous one-shot select inside the spawned goroutine returned early if loadTestCtx was already cancelled when the goroutine first ran, leaving errCh empty. The subsequent drain (mainLoopErr = <-errCh) would then block forever. Drop the select. mainLoop itself respects ctx and returns promptly on cancellation, so the goroutine can unconditionally forward its result. --- loadtest/runner.go | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/loadtest/runner.go b/loadtest/runner.go index e0d02e011..a0e3e74d6 100644 --- a/loadtest/runner.go +++ b/loadtest/runner.go @@ -391,13 +391,11 @@ func (r *Runner) Run(ctx context.Context) error { 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 From 4453492f5ca9535b431adf979558ebc27a75ff21 Mon Sep 17 00:00:00 2001 From: Ji Hwan Date: Tue, 19 May 2026 15:08:22 +0900 Subject: [PATCH 2/2] fix(loadtest): print light summary on interrupt, timeout, and error MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously, the LightSummary block lived inside postLoadTest, which was only reached from the natural-completion path. Three independent defects caused interrupt/timeout/error runs to exit with no summary at all: - log.Fatal on a non-nil mainLoop error called os.Exit(1), skipping postLoadTest and any deferred cleanup. Ctrl+C-induced ctx cancellation could surface as a regular error from in-flight setup RPCs, hitting this path and silently killing the summary. - The --time-limit timeout path returned early before postLoadTest. - log.Fatal also masked exit codes for genuinely-broken runs. Move the LightSummary call into a defer at the top of Run, with endTime captured before postLoadTest so the reported TPS reflects load-generation duration rather than post-test cleanup (refunds, --summarize receipt fetches). Drop the timeout early-return so all three exit paths share a single tail. Replace log.Fatal with log.Error and return the underlying error so Cobra propagates non-zero exit codes; filter context.Canceled so SIGINT exits 0. Also remove the duplicate preconfTracker.Stats() call from the SIGINT branch — postLoadTest already invokes it. --- loadtest/runner.go | 54 ++++++++++++++++++++++++++++------------------ 1 file changed, 33 insertions(+), 21 deletions(-) diff --git a/loadtest/runner.go b/loadtest/runner.go index a0e3e74d6..b615a53ce 100644 --- a/loadtest/runner.go +++ b/loadtest/runner.go @@ -387,6 +387,22 @@ 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() @@ -398,23 +414,17 @@ func (r *Runner) Run(ctx context.Context) error { 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 { @@ -425,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() @@ -457,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.