Skip to content

stub: bound Configure() wait in Start() and break connClosed() deadlock - #298

Open
wevans-ant wants to merge 1 commit into
containerd:mainfrom
wevans-ant:fix/stub-start-configure-deadlock
Open

wevans-ant wants to merge 1 commit into
containerd:mainfrom
wevans-ant:fix/stub-start-configure-deadlock

Conversation

@wevans-ant

Copy link
Copy Markdown

stub.Start() holds stub.Lock() for its whole body and then does an unbounded <-stub.cfgErrC waiting for the runtime's Configure() callback. If the runtime accepts RegisterPlugin but never sends Configure() — which can happen when a plugin and containerd race during node boot — Start() blocks forever with the lock held. When the connection later closes, the ttrpc OnClose handler connClosed() tries to take the same lock and deadlocks, so the plugin never observes the disconnect, WithOnClose never fires, and the plugin cannot recover.

This change:

  • replaces the naked <-stub.cfgErrC receive with a select that also honours ctx cancellation and a registrationTimeout-bounded timer, so Start() cannot block indefinitely;
  • has connClosed() do a non-blocking send of a ttrpc.ErrClosed-wrapped error onto cfgErrC before taking the lock, so a blocked Start() is woken and releases the lock (this is the mechanism from stub: cancel Start() for early connection loss. #238);
  • makes Configure()'s deferred cfgErrC send non-blocking so it cannot wedge if Start() has already given up or connClosed() has already filled the buffer.

The new adaptation-suite spec reproduces the deadlock deterministically: it lets Register succeed, blocks the plugin's Configure handler so cfgErrC is never written, then has the runtime time out the Configure RPC and close the connection. Without this change Start() never returns and the spec fails at the 3 s guard; with it, Start() returns a wrapped ttrpc.ErrClosed in ~200 ms.

Supersedes #238.

@wevans-ant
wevans-ant marked this pull request as ready for review July 3, 2026 00:28
@wevans-ant

Copy link
Copy Markdown
Author

@klihub, this picks up where #238 left off. It keeps the connClosed() -> cfgErrC non-blocking send from your PR, and adds a bounded select in Start() so the wait is also broken by ctx cancellation or a registrationTimeout timer — in case the runtime accepts Register but never sends Configure while the connection stays open.

Would be interested to know why you dropped the PR, let me know if there's something I am missing.

@wevans-ant
wevans-ant force-pushed the fix/stub-start-configure-deadlock branch from 3899911 to 147e2be Compare July 3, 2026 00:37
@klihub

klihub commented Jul 3, 2026

Copy link
Copy Markdown
Member

@klihub, this picks up where #238 left off. It keeps the connClosed() -> cfgErrC non-blocking send from your PR, and adds a bounded select in Start() so the wait is also broken by ctx cancellation or a registrationTimeout timer — in case the runtime accepts Register but never sends Configure while the connection stays open.

Would be interested to know why you dropped the PR, let me know if there's something I am missing.

@wevans-ant Thanks for picking this up and working on it.

About #238, IIRC it triggered a data read/write race in the tests. I don't recall the details, but it might have been on stub.registrationTimeout. If it was then your PR now avoids it, IIUC by sampling registrationTimeout before it could get written in Configure(). Although I think this has the side-effect that if the registration timeout has been altered by configuration on the runtime/NRI server side then the stub/client will not use this timeout but the previous locally known one instead (which should be the default one in the beginning).

@wevans-ant
wevans-ant force-pushed the fix/stub-start-configure-deadlock branch 2 times, most recently from c574df4 to 78ea274 Compare July 4, 2026 16:37
@wevans-ant

Copy link
Copy Markdown
Author

#238's failing run's logs have expired, but I reproduced its 1 ns registration-timeout test under -race and the race is on the stub.cfgErrC field, not registrationTimeout:

  1. connClosed() reads the field before taking the lock (the non-blocking send)
  2. Start() writes it under the lock (stub.cfgErrC = make(...))

with the artificially lowered timeout, the connection dies before register() has sent anything through the ttrpc client, so there's no synchronization between the two accesses and they race.

I've moved the srvErrC/cfgErrC make()s to before ttrpc.NewClient so the field is assigned before the OnClose goroutine is spawned, and added the 1 ns test as a second spec so -race keeps it covered.

Although I think this has the side-effect that if the registration timeout has been altered by configuration on the runtime/NRI server side then the stub/client will not use this timeout but the previous locally known one instead (which should be the default one in the beginning).

The timer bounds the wait for the Configure() message that carries the runtime's value, so at the point we arm it that value can't be known yet. Once Configure() does arrive, cfgErrC fires and the timer is moot. stub.registrationTimeout is still updated by Configure() for register() on reconnect and the RegistrationTimeout() accessor.

@wevans-ant

Copy link
Copy Markdown
Author

Friendly ping.

@klihub

klihub commented Aug 20, 2026

Copy link
Copy Markdown
Member

Friendly ping.

@wevans-ant Sorry, I've been AFK for a while that's why I haven't responded. I'll try to pick this up and take another look...

Start() holds stub.Lock() for its entire body and blocks on <-cfgErrC
waiting for the runtime's Configure() callback. If the runtime accepts
RegisterPlugin but never sends Configure() (observed when a plugin and
containerd race during node boot), Start() blocks forever holding the
lock. When the connection later closes, the ttrpc OnClose handler
connClosed() tries to take the same lock and deadlocks, so the plugin
never observes the disconnect and cannot recover.

Fix by:
- replacing the naked <-cfgErrC receive with a select that also honours
  ctx cancellation and a registrationTimeout-bounded timer;
- having connClosed() do a non-blocking send of a ttrpc.ErrClosed-wrapped
  error on cfgErrC before taking the lock, so a blocked Start() is woken
  and releases the lock;
- making Configure()'s deferred cfgErrC send non-blocking so it cannot
  wedge if Start() has already given up or connClosed() has already
  filled the buffer.

To keep this race-clean under -race:
- snapshot stub.registrationTimeout into a local before any goroutines
  are running, since Configure() rewrites that field without the lock;
- create srvErrC/cfgErrC before ttrpc.NewClient spawns the goroutine
  whose OnClose reads cfgErrC without the lock (this is the race that
  containerd#238's test surfaced).

Add two adaptation-suite regression tests: one that lets Register
succeed and blocks the plugin's Configure handler so cfgErrC is never
written, then has the runtime time out and close the connection
(deterministic deadlock repro); and one that has the runtime drop the
connection during registration (surfaces the cfgErrC field race under
-race).

Supersedes containerd#238.

Signed-off-by: Chris Evans <wevans@anthropic.com>
@wevans-ant
wevans-ant force-pushed the fix/stub-start-configure-deadlock branch from 78ea274 to c0686a4 Compare September 16, 2026 21:53
@wevans-ant

Copy link
Copy Markdown
Author

Rebased onto main and ported the two regression tests to the testify style from #309; the stub.go change is unchanged.

@klihub whenever you're back, this is ready for another look. On your earlier registrationTimeout question: the timer only bounds the wait for the Configure() that carries the runtime's value, and Configure() still updates the field for register() on reconnect.

cc @mikebrow @chrishenzie, who reviewed #238, which this supersedes.

Copilot AI 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.

🟡 Changes recommended

Unresolved retry-safety and stale-connection issues remain in stub.go.

Get a fresh assessment by requesting another Copilot review.

Pull request overview

This PR bounds stub.Start() while waiting for runtime configuration and adds regression coverage for connection-loss deadlocks.

Changes:

  • Adds cancellation and timeout handling for Configure().
  • Makes configuration and connection-close signaling non-blocking.
  • Adds adaptation-suite coverage for startup and registration disconnects.

Outstanding findings:

  • Critical: Mutable cfgErrC can race with retries or deliver a stale connection error.
  • Moderate: Pre-start connection failure leaves stub.conn set, preventing successful retries.
File summaries
File Description
pkg/stub/stub.go Updates startup and connection-loss handling.
pkg/adaptation/suite_test.go Adds configurable mock plugin behavior.
pkg/adaptation/adaptation_suite_test.go Adds connection-loss regression tests.
Review details
  • Files reviewed: 3/3 changed files
  • Comments generated: 2
  • Review effort level: Lite

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread pkg/stub/stub.go
Comment on lines +650 to +652
select {
case stub.cfgErrC <- fmt.Errorf("connection closed before Configure(): %w", ttrpc.ErrClosed):
default:
Comment thread pkg/stub/stub.go
Comment on lines 655 to 657
stub.Lock()
stub.close()
stub.Unlock()
@klihub

klihub commented Sep 17, 2026

Copy link
Copy Markdown
Member

@wevans-ant I'll take a look at it later today or tomorrow morning.

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.

3 participants