Skip to content

refactor(provider): single event-stream loop with one reconnect policy and leak-free sends#1022

Merged
acouvreur merged 1 commit into
mainfrom
refactor/provider-event-stream
Jul 11, 2026
Merged

refactor(provider): single event-stream loop with one reconnect policy and leak-free sends#1022
acouvreur merged 1 commit into
mainfrom
refactor/provider-event-stream

Conversation

@acouvreur

Copy link
Copy Markdown
Member

Finding

The events reconnect loop existed as two verbatim copies that had already diverged, and the four providers implemented four different terminal-error contracts.

  • docker/events.go and dockerswarm/events.go carried line-for-line duplicate streamEvents / consumeEvents / linearBackoff implementations - except swarm had grown maxReconnectAttempts = 10 and pushed a terminal error, while docker retried forever and never wrote its Err channel. The divergence is the proof of the problem: someone added the attempt budget to one copy without knowing the other existed.
  • Swarm's ten-attempt budget meant ~1 minute of daemon downtime permanently killed event delivery - the stream gives up precisely in the situation (daemon restart) it exists to survive. Consumers log "event stream permanently lost" and never resubscribe.
  • The documented contract on sablier.InstanceEventStream ("Err carries a terminal error when the stream cannot be recovered; after an error is sent both channels are closed") was honored by swarm and proxmox, violated by docker (never errors) and kubernetes (never sends or closes anything).

Goroutine leak in both copies (finding 4): events were delivered with a bare instance <- event. Once the consumer stops reading - core watchers nil out the channel on error, or return on cancellation - the producer blocks on that send forever: the ctx.Done() case of the select cannot fire once execution has entered the send. That leaks the goroutine and pins its events HTTP connection. Only the proxmox provider guarded its sends correctly.

Discovery

Found by cross-comparing the provider event implementations during the provider-layer design review. The leak reproduced against the old loop with a test that parks the goroutine in a send and cancels:

--- FAIL: TestStreamEvents_ExitsWhenConsumerGone (2.10s)
    events_leak_demo_test.go:44: event goroutine leaked: still blocked on send after context cancellation

Fix

One implementation, one policy, in pkg/provider/internal/mobyevents (internal to the provider subtree):

  • Reconnect indefinitely with linear backoff capped at 30s. Rationale: the core already pairs event streams with periodic reconciliation tickers, so a recovered daemon resumes seamlessly; a terminal give-up is strictly worse than retrying. (This intentionally drops swarm's ten-attempt budget.)
  • Context-guarded sends: the goroutine exits on cancellation even when the consumer is gone.
  • Channel policy: both channels close only on ctx cancellation; Err never receives a value from these providers.

Docker, Docker Swarm, and Podman (which reuses the docker provider) all share it. The InstanceEventStream contract comment now describes the real semantics instead of an aspiration two of four providers ignored.

Not in this PR (deliberately)

The Kubernetes watchers have the same bare-send issue inside their informer handlers, and never close their channels. Those handler bodies are being rewritten by #1018 (tombstone panics); guarding them here would conflict. Follow-up planned once #1018 lands.

Tests

  • TestStream_Reconnect - moved from the docker package (redial on connection drop).
  • TestStream_ExitsWhenConsumerGone - the leak regression; fails against the old loop (output above).
  • TestStream_ReconnectsIndefinitely - pins the policy: 15 dead connections, no terminal error, still delivering (the old swarm copy failed at attempt 10).

Provider + core short suites and golangci-lint pass; the docker/swarm testcontainers suites run in CI.

Docs

No user-facing docs impact (internal refactor; behavior change is strictly "streams no longer permanently die", which needs no operator action).

The events reconnect loop existed as two verbatim copies (docker and
dockerswarm) that had already diverged: docker retried forever and never
wrote its Err channel, while swarm gave up after ten attempts and emitted a
terminal error - permanently killing event delivery after roughly one
minute of daemon downtime, even though the daemon coming back is the common
case. A maintainer patching one copy had no way to know the other existed;
the attempt budget was added to swarm only.

Both copies also sent events with a bare channel send. Once the consumer
stops reading (the core watchers nil out the channel on error or return on
cancellation), the producer goroutine blocks on that send forever, leaking
the goroutine and pinning its events HTTP connection. Only the proxmox
provider guarded its sends.

The loop now lives once in pkg/provider/internal/mobyevents with a single
documented policy: reconnect indefinitely with linear backoff capped at
30s, close both channels only on context cancellation, never emit terminal
errors, and guard every send with the context. Docker, Docker Swarm and
(through the docker provider) Podman share it. The
sablier.InstanceEventStream contract is updated to describe the real
semantics.

The old docker unit test moves to the shared package, joined by a leak
regression test (the old loop fails it: the goroutine is still blocked two
seconds after cancellation) and a policy test pinning that the stream
outlives the old ten-attempt budget without a terminal error.
@github-actions github-actions Bot added the provider Issue related to a provider label Jul 11, 2026
@github-actions

Copy link
Copy Markdown
┌───────────────────────────────────────────────────────────────────────────────┐
│ Diff between sablier and sablier                                              │
├─────────┬─────────────────────────────────────┬──────────┬──────────┬─────────┤
│ PERCENT │ NAME                                │ OLD SIZE │ NEW SIZE │ DIFF    │
├─────────┼─────────────────────────────────────┼──────────┼──────────┼─────────┤
│ +0.54%  │ go.yaml.in/yaml/v3                  │ 312 kB   │ 314 kB   │ +1.7 kB │
│ +0.04%  │ go.opentelemetry.io/otel            │ 1.1 MB   │ 1.1 MB   │ +404 B  │
│ +0.20%  │ vendor/golang.org/x/net/http2/hpack │ 35 kB    │ 35 kB    │ +68 B   │
│ +0.01%  │ encoding                            │ 418 kB   │ 418 kB   │ +55 B   │
│ +0.46%  │ vendor/golang.org/x/sys/cpu         │ 6.4 kB   │ 6.4 kB   │ +29 B   │
│ +0.01%  │ log                                 │ 126 kB   │ 126 kB   │ +16 B   │
│ +0.00%  │ go.yaml.in/yaml/v2                  │ 275 kB   │ 275 kB   │ +11 B   │
│ +0.00%  │ reflect                             │ 340 kB   │ 340 kB   │ +8 B    │
│ +0.01%  │ github.com/spf13/viper              │ 73 kB    │ 73 kB    │ +7 B    │
│ +0.00%  │ k8s.io/apimachinery                 │ 1.8 MB   │ 1.8 MB   │ +2 B    │
│ +0.01%  │ k8s.io/utils                        │ 32 kB    │ 32 kB    │ +2 B    │
│ +0.00%  │ github.com/emicklei/go-restful/v3   │ 134 kB   │ 134 kB   │ +1 B    │
│ +0.00%  │ github.com/spf13/cobra              │ 239 kB   │ 239 kB   │ +1 B    │
│ -0.00%  │ crypto                              │ 1.9 MB   │ 1.9 MB   │ -1 B    │
│ -0.01%  │ github.com/pmezard/go-difflib       │ 17 kB    │ 17 kB    │ -1 B    │
│ -0.00%  │ net                                 │ 1.7 MB   │ 1.7 MB   │ -1 B    │
│ -0.00%  │ github.com/luthermonson/go-proxmox  │ 333 kB   │ 333 kB   │ -3 B    │
│ -0.00%  │ github.com/google/gnostic-models    │ 1.6 MB   │ 1.6 MB   │ -7 B    │
│ -0.02%  │ golang.org/x/sys                    │ 46 kB    │ 46 kB    │ -10 B   │
│ -0.00%  │ k8s.io/api                          │ 17 MB    │ 17 MB    │ -14 B   │
│ -0.01%  │ k8s.io/klog/v2                      │ 124 kB   │ 124 kB   │ -18 B   │
│ -0.31%  │ internal/cpu                        │ 6.1 kB   │ 6.1 kB   │ -19 B   │
│ -0.11%  │ vendor/golang.org/x/net/idna        │ 22 kB    │ 22 kB    │ -25 B   │
│ -0.00%  │ k8s.io/client-go                    │ 14 MB    │ 14 MB    │ -34 B   │
│ -0.00%  │ runtime                             │ 3.3 MB   │ 3.3 MB   │ -37 B   │
│ -0.00%  │ golang.org/x/net                    │ 902 kB   │ 902 kB   │ -43 B   │
│ -0.03%  │ sigs.k8s.io/json                    │ 173 kB   │ 173 kB   │ -55 B   │
│ -0.02%  │ github.com/gin-gonic/gin            │ 337 kB   │ 337 kB   │ -56 B   │
│ -0.46%  │ go.opentelemetry.io/auto/sdk        │ 89 kB    │ 88 kB    │ -404 B  │
│ -0.55%  │ gopkg.in/yaml.v3                    │ 306 kB   │ 304 kB   │ -1.7 kB │
│ -0.54%  │ github.com/sablierapp/sablier       │ 654 kB   │ 651 kB   │ -3.6 kB │
├─────────┼─────────────────────────────────────┼──────────┼──────────┼─────────┤
│ -0.00%  │ .rodata                             │ 2.6 MB   │ 2.6 MB   │ -95 B   │
├─────────┼─────────────────────────────────────┼──────────┼──────────┼─────────┤
│ +0.00%  │ sablier                             │ 62 MB    │ 62 MB    │ +0 B    │
│         │ sablier                             │          │          │         │
└─────────┴─────────────────────────────────────┴──────────┴──────────┴─────────┘

@github-actions

Copy link
Copy Markdown

Test Results

✅ All tests passed! | 767 tests in 137.821s

View HTML Test Report

@sonarqubecloud

Copy link
Copy Markdown

Quality Gate Failed Quality Gate failed

Failed conditions
77.8% Coverage on New Code (required ≥ 80%)

See analysis details on SonarQube Cloud

@acouvreur acouvreur merged commit 642d0fa into main Jul 11, 2026
7 of 8 checks passed
@acouvreur acouvreur deleted the refactor/provider-event-stream branch July 11, 2026 15:01
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

provider Issue related to a provider

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant