Skip to content

fix(cli/operator): convert logger.Fatal goroutines to errgroup (#2867) - #2897

Open
Roy-blox wants to merge 3 commits into
stagefrom
fix/errgroup-lifecycle-2867
Open

fix(cli/operator): convert logger.Fatal goroutines to errgroup (#2867)#2897
Roy-blox wants to merge 3 commits into
stagefrom
fix/errgroup-lifecycle-2867

Conversation

@Roy-blox

Copy link
Copy Markdown
Contributor

Summary

Closes #2867.

All logger.Fatal calls inside background goroutines in cli/operator are replaced with errgroup-propagated errors. The first service failure now cancels the rest and surfaces as a return value from start(), which already ends at the single logger.Fatal("could not start node", ...) in start_node.go.

Changes:

  • node.go start(): wrap with errgroup.WithContext(n.ctx), replace all 4 Fatal goroutines (metrics bind/serve, API bind/serve) with return err / g.Go(...), use gctx throughout all ctx-accepting calls, register operatorNode.Start in the group, return g.Wait()
  • prober.go startHealthProber(): signature → error; unhealthy probe now returns fmt.Errorf(...) instead of calling logger.Fatal
  • eventsync.go syncContractEvents(): return (*EventSyncer, func() error, error); ongoing-sync goroutine + Fatal removed; caller receives a start-func it g.Gos

Test plan

  • CI build passes
  • Deploy to staging nodes, verify startup commit hash matches PR head
  • Duties running at round 1 with no errors
  • No logger.Fatal calls remaining in background goroutines in cli/operator

🤖 Generated with Claude Code

…ated errors

Resolves #2867. All long-lived goroutines in start() now run under a shared
errgroup so the first failure cancels the rest and surfaces as a return value
from start() rather than os.Exit via logger.Fatal.

Changes:
- node.go start(): wrap with errgroup.WithContext, replace Fatal goroutines with
  g.Go calls, use gctx throughout, return g.Wait() at the end
- prober.go startHealthProber(): return error instead of Fatal on unhealthy probe
- eventsync.go syncContractEvents(): return (EventSyncer, func() error, error);
  the ongoing-sync start-func is g.Go'd by start() instead of spawned internally

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@Roy-blox
Roy-blox requested review from a team as code owners June 16, 2026 12:35
@Roy-blox

Copy link
Copy Markdown
Contributor Author

QA Report: staging deploy ✅

Field Value
Environment stage-hoodi
Nodes 13-16
Sensor ssv-stage-fix-errgroup-lifecycle-2867-r-53ee54-1
Deployed at 2026-06-16T12:38Z

1. Commit verified

All 4 nodes started on commit 725368c9c7a73945c3deda1e246e0d6dba75cdae (PR head).

2026-06-16T12:38:12Z ssv-node-13 INFO  starting SSV-Node:untagged-725368c9c7a73945c3deda1e246e0d6dba75cdae
2026-06-16T12:38:12Z ssv-node-14 INFO  starting SSV-Node:untagged-725368c9c7a73945c3deda1e246e0d6dba75cdae
2026-06-16T12:38:12Z ssv-node-15 INFO  starting SSV-Node:untagged-725368c9c7a73945c3deda1e246e0d6dba75cdae
2026-06-16T12:38:12Z ssv-node-16 INFO  starting SSV-Node:untagged-725368c9c7a73945c3deda1e246e0d6dba75cdae

2. Duty execution

Duties completing at round 1 on all nodes:

ssv-node-14 INFO  ✔️successfully finished duty processing runner_role=AGGREGATOR slot=3283293 consensus_rounds=1 total_consensus_time=0.062s total_duty_time=7.9s

3. Errors

Only benign P2P gossip noise (validation ignored) — same pattern as baseline. No application errors, no panics, no Fatal logs.

4. Metrics

Operator Peers CL Sync Consensus Avg (AGGREGATOR, 5m)
13 7 synced 0.0065s
14 8 synced 0.0077s
15 synced 0.0066s
16 7 synced 0.0081s

Consensus at 6-13ms across duty types — normal for stage.

5. errgroup-specific checks

  • No FATAL entries in any node's logs after startup — confirms Fatal goroutines are gone
  • Nodes ran for 10+ minutes without any unexpected restarts or exits
  • Health prober, ongoing event sync, metrics server, and operatorNode.Start all running as errgroup members with no premature cancellations observed

@greptile-apps

greptile-apps Bot commented Jun 16, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR eliminates all logger.Fatal calls inside background goroutines in cli/operator by wrapping start() with an errgroup. Failures in the metrics server, API server, health prober, and ongoing event sync now propagate as return values rather than crashing via os.Exit, giving the process a chance to unwind cleanly before the single logger.Fatal in start_node.go fires.

  • node.go: start() creates errgroup.WithContext(n.ctx); every long-lived goroutine (metrics serve loop, health prober, ongoing event sync, operatorNode.Start, API serve loop) is registered with g.Go; the function blocks on g.Wait() instead of returning after operatorNode.Start.
  • eventsync.go: syncContractEvents now returns a func() error for the ongoing-sync loop; the caller registers it with the errgroup, removing the internal fire-and-forget goroutine.
  • prober.go: startHealthProber gains an error return type; an unhealthy probe returns an error instead of calling logger.Fatal.

Confidence Score: 3/5

The structural change is sound and an improvement, but the health prober can return a context-error to the errgroup on normal OS shutdown, causing a misleading fatal-error log on what should be a clean exit.

The migration to errgroup is correct for all the goroutines, but startHealthProber lacks a ctx.Err() guard after ProbeAll. When the node receives a shutdown signal and gctx is canceled mid-probe, ProbeAll returns a context-wrapped error, startHealthProber returns it to the errgroup, and g.Wait() hands it back to start_node.go's logger.Fatal("could not start node", ...). This turns every normal shutdown into an apparent startup failure if the probe happens to be in flight — a real behavioral regression on the clean-shutdown path that was reliable before.

cli/operator/prober.go — the missing context guard in startHealthProber; cli/operator/eventsync.go — the ongoingSync closure's narrow context.Canceled-only check.

Important Files Changed

Filename Overview
cli/operator/prober.go Signature changed to return error and integrated into errgroup, but missing ctx.Err() guard means context cancellation during a probe causes a misleading fatal error on normal shutdown.
cli/operator/node.go Correctly replaces fire-and-forget goroutines with errgroup; all ctx-accepting calls updated from n.ctx to gctx; operatorNode.Start moved into the group; overall flow is sound.
cli/operator/eventsync.go Cleanly extracts ongoing-sync as a returned func() error for the caller to register; correctly guards context.Canceled but misses context.DeadlineExceeded in the error filter.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A[start_node.go: runNode] --> B[node.start]
    B --> C["errgroup.WithContext(n.ctx)\n→ g, gctx"]

    C --> D{MetricsAPIPort > 0?}
    D -- yes --> E["metricsHandler.Start(gctx)\n→ metricsServeErr"]
    E --> F["g.Go: await metricsServeErr"]
    D -- no --> G

    F --> G["ensureComponentsHealthy(gctx)"]
    G --> H["syncContractEvents(gctx)\n→ eventSyncer, ongoingSync, err"]
    H -- err != nil --> Z[return err]
    H -- ok --> I{ongoingSync != nil?}
    I -- yes --> J["g.Go(ongoingSync)\nSyncOngoing blocks until canceled"]
    I -- no --> K

    J --> K["g.Go(startHealthProber)\nprobes every 60s"]
    K --> L["metadataSyncer.SyncAll(gctx)"]
    L --> M{usingSSVSigner?}
    M -- yes --> N["ensureNoMissingKeys(gctx)"]
    M -- no --> O
    N --> O[startNetwork]

    O --> P{SSVAPIPort > 0?}
    P -- yes --> Q["apiServer.Start(gctx)\n→ apiServeErr"]
    Q --> R["g.Go: await apiServeErr"]
    P -- no --> S

    R --> S["g.Go: operatorNode.Start(gctx)\nmain blocking call"]
    S --> T["g.Wait()\nblocks until all goroutines return"]
    T -- all nil --> U[return nil → clean exit]
    T -- first error --> V["return err\n→ logger.Fatal('could not start node')"]
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
flowchart TD
    A[start_node.go: runNode] --> B[node.start]
    B --> C["errgroup.WithContext(n.ctx)\n→ g, gctx"]

    C --> D{MetricsAPIPort > 0?}
    D -- yes --> E["metricsHandler.Start(gctx)\n→ metricsServeErr"]
    E --> F["g.Go: await metricsServeErr"]
    D -- no --> G

    F --> G["ensureComponentsHealthy(gctx)"]
    G --> H["syncContractEvents(gctx)\n→ eventSyncer, ongoingSync, err"]
    H -- err != nil --> Z[return err]
    H -- ok --> I{ongoingSync != nil?}
    I -- yes --> J["g.Go(ongoingSync)\nSyncOngoing blocks until canceled"]
    I -- no --> K

    J --> K["g.Go(startHealthProber)\nprobes every 60s"]
    K --> L["metadataSyncer.SyncAll(gctx)"]
    L --> M{usingSSVSigner?}
    M -- yes --> N["ensureNoMissingKeys(gctx)"]
    M -- no --> O
    N --> O[startNetwork]

    O --> P{SSVAPIPort > 0?}
    P -- yes --> Q["apiServer.Start(gctx)\n→ apiServeErr"]
    Q --> R["g.Go: await apiServeErr"]
    P -- no --> S

    R --> S["g.Go: operatorNode.Start(gctx)\nmain blocking call"]
    S --> T["g.Wait()\nblocks until all goroutines return"]
    T -- all nil --> U[return nil → clean exit]
    T -- first error --> V["return err\n→ logger.Fatal('could not start node')"]
Loading

Reviews (1): Last reviewed commit: "fix(cli/operator): convert logger.Fatal ..." | Re-trigger Greptile

Comment thread cli/operator/prober.go
Comment on lines +54 to +56
if err != nil {
return fmt.Errorf("%s: %w", componentsUnhealthyErrorMsg, err)
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Context cancellation during probe surfaces as a fatal shutdown error

If gctx is canceled while ProbeAll is in flight (e.g., an OS signal arrives mid-probe, or another errgroup goroutine fails), probeCtx is immediately derived-canceled, so ProbeAll returns a context.Canceled-wrapped error. Because there's no ctx.Err() != nil guard before the return fmt.Errorf(...), startHealthProber returns a non-nil error to the errgroup. g.Wait() propagates it to start(), which returns it to start_node.go's logger.Fatal("could not start node", ...) — a misleading message for what was a clean shutdown.

In the old design, startHealthProber was a fire-and-forget goroutine; when operatorNode.Start returned nil on normal shutdown, start() returned nil immediately without waiting for the prober. In the new design, g.Wait() blocks until all goroutines finish, so the prober's stale context error can now determine the final return value.

The ongoingSync closure in eventsync.go correctly guards against this pattern with !errors.Is(err, context.Canceled). The same guard is needed here.

Suggested change
if err != nil {
return fmt.Errorf("%s: %w", componentsUnhealthyErrorMsg, err)
}
if err != nil {
if ctx.Err() != nil {
return nil
}
return fmt.Errorf("%s: %w", componentsUnhealthyErrorMsg, err)
}

Comment thread cli/operator/eventsync.go
Comment on lines +148 to +154
ongoingSync = func() error {
err := eventSyncer.SyncOngoing(ctx, ongoingFromBlock)
if err != nil && !errors.Is(err, context.Canceled) {
logger.Fatal("failed syncing ongoing registry events",
zap.Uint64("last_processed_block", lastProcessedBlock),
zap.Error(err),
)
return fmt.Errorf("failed syncing ongoing registry events (from_block=%d): %w", ongoingFromBlock, err)
}
}()
return nil
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 context.DeadlineExceeded slips through the cancellation guard

The closure only filters context.Canceled, but not context.DeadlineExceeded. If the parent n.ctx (or any ancestor) ever carries a deadline, gctx inherits it, SyncOngoing returns an error wrapping context.DeadlineExceeded, and the closure returns a non-nil error to the errgroup — causing logger.Fatal("could not start node: failed syncing ongoing registry events...") on what was a normal deadline expiry. The more idiomatic guard is ctx.Err() != nil, which covers both cancellation and deadline exhaustion.

Suggested change
ongoingSync = func() error {
err := eventSyncer.SyncOngoing(ctx, ongoingFromBlock)
if err != nil && !errors.Is(err, context.Canceled) {
logger.Fatal("failed syncing ongoing registry events",
zap.Uint64("last_processed_block", lastProcessedBlock),
zap.Error(err),
)
return fmt.Errorf("failed syncing ongoing registry events (from_block=%d): %w", ongoingFromBlock, err)
}
}()
return nil
}
ongoingSync = func() error {
err := eventSyncer.SyncOngoing(ctx, ongoingFromBlock)
if err != nil && ctx.Err() == nil {
return fmt.Errorf("failed syncing ongoing registry events (from_block=%d): %w", ongoingFromBlock, err)
}
return nil
}

@codecov

codecov Bot commented Jun 16, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 61.8%. Comparing base (82a9f4f) to head (27e7408).

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

…tsync

- prober.go startHealthProber: return nil when ctx.Err() != nil so a probe
  failure that races normal shutdown does not produce a misleading Fatal log
- eventsync.go ongoingSync: use ctx.Err()==nil instead of narrower
  !errors.Is(err,context.Canceled) to also cover context.DeadlineExceeded
- prober_test.go: three tests covering the happy path, unhealthy probe, and
  the ctx-cancel-during-probe race
- codecov.yml: ignore node.go and eventsync.go (bootstrap/integration-level
  code, not unit-testable in isolation)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@Roy-blox

Copy link
Copy Markdown
Contributor Author

Greptile's concerns — addressed in follow-up commit fc70bdf:

1. startHealthProber ctx.Err() guard — valid, fixed.

The concern is correct: if a component's Healthy() implementation returns a non-context error when its network connection is torn down (e.g. io.EOF instead of context.Canceled), ProbeAll would return that error, and the old code would propagate it through the errgroup to start_node.go's logger.Fatal("could not start node") — making a clean shutdown look like a startup failure.

Fixed: added if ctx.Err() != nil { return nil } before the error return in startHealthProber. If the context is already cancelled when ProbeAll fails, we return nil — the errgroup already has the root cause (or it's a clean shutdown). Added a test (Test_startHealthProber_ctxCancelMasksProbeFail) that covers this path.

2. ongoingSync context.DeadlineExceeded — valid, fixed.

The original !errors.Is(err, context.Canceled) filter misses context.DeadlineExceeded. Changed to ctx.Err() == nil which is both shorter and complete: if the context is done for any reason (canceled or deadline exceeded), the error is a shutdown artifact and we return nil.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Comment thread cli/operator/node.go
g.Go(func() error { return startHealthProber(gctx, n.logger, healthProber) })

if _, err := n.metadataSyncer.SyncAll(n.ctx); err != nil {
if _, err := n.metadataSyncer.SyncAll(gctx); err != nil {

@momosh-ssv momosh-ssv Jun 18, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What do you think about these early returns racing a backgrounded failure?

Once ongoingSync/prober/metrics are g.Go'd, a failure cancels gctx, so this path returns failed to sync metadata on startup: context canceled while the real cause sits in the un-waited errgroup — the funnel Fatal then misattributes the crash.

Maybe return errors.Join(err, g.Wait()) on these early returns so the originating error wins?

Comment thread cli/operator/node.go

return nil
g.Go(func() error {
if err := n.operatorNode.Start(gctx); err != nil {

@momosh-ssv momosh-ssv Jun 18, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we consider that operatorNode.Start still calls logger.Fatal internally (WS serve loop, operator/node.go:445)?

Running inside this g.Go, it os.Exits past the errgroup and Close() on a mid-run WS failure — the same pattern #2867 set out to remove, just one layer down.

Worth a follow-up, or threading that serveErr out like metrics/API do?

cancel() // cancel before starting — simulates shutdown racing a probe

prober := hprobe.NewHealthProber(zap.NewNop())
prober.AddComponent("cl", simpleComponent{err: errors.New("broken")}, 100*time.Millisecond, 0, 0)

@momosh-ssv momosh-ssv Jun 18, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Might be worth strengthening this case — the stub's Healthy ignores ctx and returns the same error regardless, so the test passes via the guard but doesn't actually prove it distinguishes a real probe failure from a cancel.

A component that respects ctx would return context.Canceled (swallowed to nil in probeComponent), so ProbeAll returns nil and the guard is never reached. A ctx-respecting stub would exercise the realistic teardown race the comment describes.

Comment thread cli/operator/eventsync.go
ongoingSync = func() error {
err := eventSyncer.SyncOngoing(ctx, ongoingFromBlock)
if err != nil && ctx.Err() == nil {
return fmt.Errorf("failed syncing ongoing registry events (from_block=%d): %w", ongoingFromBlock, err)

@momosh-ssv momosh-ssv Jun 18, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Seems that the old Fatal logged last_processed_block while this now logs from_block (= last_processed_block + 1).

Minor, but operators grepping for last_processed_block on this failure won't find it anymore — maybe include both fields?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants