Skip to content

🐛 respawn cloudevents receiver after spontaneous Receive error - #222

Draft
hbhushan3 wants to merge 1 commit into
open-cluster-management-io:mainfrom
hbhushan3:fix/receiver-goroutine-resubscribe
Draft

🐛 respawn cloudevents receiver after spontaneous Receive error#222
hbhushan3 wants to merge 1 commit into
open-cluster-management-io:mainfrom
hbhushan3:fix/receiver-goroutine-resubscribe

Conversation

@hbhushan3

@hbhushan3 hbhushan3 commented May 1, 2026

Copy link
Copy Markdown

What

Recover the baseClient receiver lifecycle when transport.Receive returns an error spontaneously while both the parent context and the receiver context are still alive.

When this happens, the receiver goroutine launched inside (*baseClient).subscribe (pkg/cloudevents/generic/clients/baseclient.go) now:

  1. Sends stopReceiverSignal on receiverChan — clears the startReceiving flag and cancels the dead receiver context.
  2. Sends a tickle on subscribeChan — the subscribe goroutine retries transport.Subscribe() (with DelayFn backoff) and, on success, emits a fresh startReceiverSignal that respawns the receiver.

Both sends are non-blocking (select/default) and the target channels are size-1 buffered, so the recovery path is idempotent: if the transport's ErrorChan also fires for the same failure, the duplicate signals are coalesced and no extra reconnect is performed.

A new white-box test, TestReceiverRecoveryAfterSpontaneousReceiveError, drives a fake CloudEventTransport whose first Receive call returns rpc error: code = Canceled desc = context canceled and whose ErrorChan is never written. It asserts that Receive is invoked at least twice within 10s — proving the recovery path runs end-to-end.

Why

Production symptom

In ARO HCP, aro-hcp-backend pods (which embed this SDK as the maestro consumer) silently stopped processing maestro bundle status updates. A single log line was emitted at the moment of failure and then the pod went permanently silent on the receive path until it was manually restarted hours later:

failed to receive cloudevents err="rpc error: code = Canceled desc = context canceled"

The line originates here: https://github.com/open-cluster-management-io/sdk-go/blob/v1.2.0/pkg/cloudevents/generic/clients/baseclient.go#L308

After this point, no further Receive invocation was ever made. Both maestro consumer controllers (cluster-scoped and nodepool-scoped) sharing the broken baseClient stopped making progress; cluster/nodepool ARM resources stayed in transient states until the pod was rolled.

Internal tracking: AROSLSRE-755

Root cause

Inside (*baseClient).subscribe, the case startReceiverSignal: branch spawns:

go func() {
    if err := c.transport.Receive(receiverCtx, ...); err != nil {
        runtime.HandleErrorWithContext(ctx, err, "failed to receive cloudevents")
    }
}()

If Receive returns on its own, this goroutine exits and nothing restarts it. The state machine relies on either stopReceiverSignal (sent by the connection monitor when it sees a transport error) or startReceiverSignal (sent by the subscribe goroutine after a successful Subscribe) to reset startReceiving and respawn — but neither will fire, because:

  • startReceiving stays true, so subsequent startReceiverSignals are ignored (baseclient.go:284-289).
  • The subscribe goroutine is already idle, awaiting a tickle on subscribeChan.
  • The connection monitor only reads transport.ErrorChan(). For the gRPC transport, errors from a spontaneously-failing inbound subscribe stream are reported via select { case errorChan <- err: default: } over an unbuffered errorChan (pkg/cloudevents/generic/options/grpc/protocol/protocol.go:178-183, pkg/cloudevents/generic/options/grpc/agentoptions.go:29). The single reader is only available during the steady-state select; if it is mid-Connect/Close/DelayFn, the error is silently dropped — which is exactly what the production traces show.

This is a different gap than #183 (which also touches reconnect, but addresses the connection-monitor reader path; it does not respawn a receiver goroutine that exited on its own).

Reproduction (deterministic)

The new regression test drives the exact production failure mode — a transport whose Receive returns a transient error while ErrorChan stays silent — and asserts that the client recovers by invoking Receive again. Without this PR the test fails after its 10s deadline; with this PR it passes in tens of milliseconds.

Before this PR (run against main @ v1.3.0, with only the new test cherry-picked):

=== RUN   TestReceiverRecoveryAfterSpontaneousReceiveError
E... failed to receive cloudevents err="rpc error: code = Canceled desc = context canceled"
    baseclient_test.go:94: receiver did not recover: receive=1 subscribe=1 (expected receive>=2)
--- FAIL: TestReceiverRecoveryAfterSpontaneousReceiveError (10.01s)
FAIL    open-cluster-management.io/sdk-go/pkg/cloudevents/generic/clients    10.020s

receive=1 subscribe=1 is the smoking gun: after the spontaneous error the receiver goroutine exited, startReceiving stayed true, no new Receive was ever started — exactly the behavior observed in production.

After this PR:

=== RUN   TestReceiverRecoveryAfterSpontaneousReceiveError
E... failed to receive cloudevents err="rpc error: code = Canceled desc = context canceled"
--- PASS: TestReceiverRecoveryAfterSpontaneousReceiveError (0.02s)
PASS
ok      open-cluster-management.io/sdk-go/pkg/cloudevents/generic/clients    0.024s

Full package: ok ... 14.037s. go vet ./... and go build ./... are clean.

Notes for reviewers

  • The recovery branch only fires when ctx.Err() == nil && receiverCtx.Err() == nil, so genuine shutdown / explicit stopReceiverSignal paths are unaffected.
  • Both recovery sends use select/default against size-1 buffered channels (matching the existing house-style at baseclient.go:117-141, :218-224, :228-234), so the new path cannot deadlock and cannot double-trigger if ErrorChan also fires for the same failure.
  • No public API changes; no changes to transport implementations.

Summary by CodeRabbit

Release Notes

  • Bug Fixes

    • Implemented automatic recovery for transient receive errors, enabling the client to detect unexpected transport failures and automatically restart subscriptions.
  • Tests

    • Added comprehensive test coverage for recovery scenarios to verify resilience under failure conditions.

@openshift-ci
openshift-ci Bot requested review from deads2k and qiujian16 May 1, 2026 18:57
@openshift-ci

openshift-ci Bot commented May 1, 2026

Copy link
Copy Markdown

[APPROVALNOTIFIER] This PR is NOT APPROVED

This pull-request has been approved by: hbhushan3
Once this PR has been reviewed and has the lgtm label, please assign qiujian16 for approval. For more information see the Code Review Process.

The full list of commands accepted by this bot can be found here.

Details Needs approval from an approver in each of these files:

Approvers can indicate their approval by writing /approve in a comment
Approvers can cancel approval by writing /approve cancel in a comment

@coderabbitai

coderabbitai Bot commented May 1, 2026

Copy link
Copy Markdown

Walkthrough

The receiver goroutine is enhanced to implement recovery logic. When Receive returns unexpectedly, the goroutine logs the condition, non-blockingly signals the stop and subscription channels to trigger resubscription and transport recovery, then exits.

Changes

Cohort / File(s) Summary
Receiver Recovery Logic
pkg/cloudevents/generic/clients/baseclient.go
Adds lifecycle recovery checks after transport.Receive returns. Implements non-blocking signal handling for stopReceiverSignal and subscribeChan to recover from transient transport-level failures.
Receiver Recovery Test
pkg/cloudevents/generic/clients/baseclient_test.go
Introduces TestReceiverRecoveryAfterSpontaneousReceiveError and a recoveringTransport mock that simulates initial Receive failure followed by blocking behavior, validating the recovery and resubscription flow.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~22 minutes

Suggested labels

approved, lgtm

Suggested reviewers

  • deads2k
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 50.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately describes the main change—respawning the cloudevents receiver after a spontaneous Receive error—and uses the appropriate 🐛 prefix per repository conventions.
Description check ✅ Passed The description includes a comprehensive summary of what changed and why, with detailed context on production impact, root cause analysis, and reproduction evidence—fully addressing the template sections.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share
Review rate limit: 0/1 reviews remaining, refill in 60 minutes.

Comment @coderabbitai help to get the list of available commands and usage tips.

@mikeshng mikeshng left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

ARO HCP I am assuming maestro related. CC @rokej @jnpacker

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@pkg/cloudevents/generic/clients/baseclient.go`:
- Around line 321-346: In the recovery branch where the receiver unexpectedly
exits (logging "cloudevents receiver exited unexpectedly, triggering
resubscribe"), call c.transport.Close(receiverCtx) to close the previous
transport before sending stopReceiverSignal and re-triggering subscribe; this
ensures OpenInbound's goroutines and connection are cleaned up (referencing
c.transport.Close, OpenInbound, stopReceiverSignal, receiverChan, subscribeChan
and receiverCtx) — place the Close call immediately prior to writing to
c.receiverChan and c.subscribeChan and handle any returned error with a log.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 16312172-f863-4eb0-80ed-f74fe0f63cc0

📥 Commits

Reviewing files that changed from the base of the PR and between 9dbb933 and 41b3dd5.

📒 Files selected for processing (2)
  • pkg/cloudevents/generic/clients/baseclient.go
  • pkg/cloudevents/generic/clients/baseclient_test.go

Comment on lines +321 to +346
// Receive returned spontaneously (e.g. transient gRPC Canceled /
// Unavailable on the inbound subscribe stream) while the parent
// context is still alive and the transport-level ErrorChan never
// fired. Without this branch, startReceiving stays true, no future
// startReceiverSignal can respawn the receiver, and bundle status
// updates are silently dropped until the pod restarts.
//
// Recover by sending stopReceiverSignal (clears startReceiving and
// cancels the now-dead receiver context) and re-triggering the
// subscribe goroutine, which will retry Subscribe with backoff and
// emit a fresh startReceiverSignal on success.
logger.V(2).Info("cloudevents receiver exited unexpectedly, triggering resubscribe")
select {
case c.receiverChan <- stopReceiverSignal:
// Signal sent successfully
default:
// Receiver channel is unavailable, that's okay - don't block
logger.V(2).Info("stopReceiverSignal not sent, receiver channel unavailable")
}
select {
case c.subscribeChan <- struct{}{}:
// Signal sent successfully
default:
// Subscribe channel is unavailable, that's okay - don't block
logger.V(2).Info("subscribe signal not sent, subscribe channel is unavailable")
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== CloudEventTransport interface =="
rg -nC3 'type CloudEventTransport interface|Connect\(ctx context.Context\) error|Subscribe\(ctx context.Context\) error|Receive\(ctx context.Context, .*ReceiveHandlerFn\) error|Close\(ctx context.Context\) error|ErrorChan\(\) <-chan error' pkg/cloudevents

echo
echo "== Transport implementations: subscribe/receive/close/error handling =="
rg -nC4 'func .*Subscribe\(ctx context.Context\) error|func .*Receive\(ctx context.Context, .*ReceiveHandlerFn\) error|func .*Close\(ctx context.Context\) error|reconnectErrorChan|OpenInbound|heartbeat|ErrorChan\(' pkg/cloudevents

echo
echo "== Existing cleanup path around receive-side failures =="
rg -nC5 'reconnectErrorChan|Close\(\)|OpenInbound|context canceled|Unavailable|Canceled' pkg/cloudevents

Repository: open-cluster-management-io/sdk-go

Length of output: 50390


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "=== gRPC protocol OpenInbound and receiver implementation ==="
rg -nA30 'func \(p \*Protocol\) OpenInbound' pkg/cloudevents/generic/options/grpc/protocol/protocol.go

echo
echo "=== startEventsReceiver and heartbeat watcher in protocol ==="
rg -nA20 'func.*startEventsReceiver|startHeartbeatWatcher|cancelReceiverAndWatcher' pkg/cloudevents/generic/options/grpc/protocol/protocol.go

echo
echo "=== closeChan usage and Close cleanup ==="
rg -nB5 -A10 'closeChan' pkg/cloudevents/generic/options/grpc/protocol/protocol.go

Repository: open-cluster-management-io/sdk-go

Length of output: 3815


Call transport.Close(ctx) in the recovery path before re-triggering subscribe.

When a transport's Receive() returns spontaneously (e.g., transient gRPC Unavailable while parent context is alive), the recovery code sends stopReceiverSignal and re-tickles subscribe. However, it does not call transport.Close(), leaving the previous OpenInbound() goroutine stuck.

In the gRPC protocol implementation, OpenInbound() blocks at a select waiting for either subCtx.Done() or closeChan (line 145). If Close() is not called, closeChan is never signaled, so OpenInbound() never reaches the cancel() call (line 149) that terminates the receiver and heartbeat watcher goroutines. This results in:

  • The old subscription context remaining active indefinitely
  • The healthChecker goroutine continuing to run on the stale context
  • The old gRPC connection never being closed (line 152)

Add c.transport.Close(receiverCtx) before sending the resubscribe signal to ensure proper cleanup of the previous subscription's internal goroutines and connection.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@pkg/cloudevents/generic/clients/baseclient.go` around lines 321 - 346, In the
recovery branch where the receiver unexpectedly exits (logging "cloudevents
receiver exited unexpectedly, triggering resubscribe"), call
c.transport.Close(receiverCtx) to close the previous transport before sending
stopReceiverSignal and re-triggering subscribe; this ensures OpenInbound's
goroutines and connection are cleaned up (referencing c.transport.Close,
OpenInbound, stopReceiverSignal, receiverChan, subscribeChan and receiverCtx) —
place the Close call immediately prior to writing to c.receiverChan and
c.subscribeChan and handle any returned error with a log.

@hbhushan3
hbhushan3 marked this pull request as draft June 3, 2026 19:16
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants