Skip to content

stub: cancel Start() for early connection loss.#238

Closed
klihub wants to merge 1 commit into
containerd:mainfrom
klihub:fixes/cancel-start-on-early-conn-loss
Closed

stub: cancel Start() for early connection loss.#238
klihub wants to merge 1 commit into
containerd:mainfrom
klihub:fixes/cancel-start-on-early-conn-loss

Conversation

@klihub

@klihub klihub commented Oct 9, 2025

Copy link
Copy Markdown
Member

If our connection gets closed before we had a chance to get Configure()'d by the runtime, cancel Start()'s wait for the result by letting it know about the failure. Otherwise the stub might get stuck in Start().

@klihub klihub requested review from chrishenzie and mikebrow October 9, 2025 07:09
Comment thread pkg/stub/stub.go
func (stub *stub) connClosed() {
select {
// if our connection gets closed before we get Configure()'d, let Start() know
case stub.cfgErrC <- ttrpc.ErrClosed:

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.

since we are overloading the cfgErrC is there any chance we end up with two writers to the channel in one Start?

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.

So if connClosed writes to cfgErrc while Configure is running, the deferred send in Configure would block forever and leak a goroutine?

What if we make the send in Configure non-blocking as well? That would ensure whichever function gets there first wins.

defer func() {
	select {
	case stub.cfgErrC <- retErr:
	default:
	}
}()

What do you think?

@klihub klihub Oct 9, 2025

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

I am not sure, because I don't know (and did not try to check/test) how racy a socket connection close soon after a ttrpc message sending over the same socket can end up being wrt. ttrpc delivering the message or an onClose() callback. Therefore I wanted to err on the side of safety, so although cfgErrC is a buffered channel with a capacity of 1, I still decided to do an attempted/non-blocking send here with a select, so we can't get stuck here. If sending here fails, it means that there is already a pending/unreceived cfgErr in the channel, which will nudge Start() out of the wait-receive, so no harm is done if the extra ttrpc.ErrClosed attempted to send here is lost. And if Configure() has not failed yet, or the failure error has not been delivered over the channel yet, sending will succeed as the channel is empty, which again should nudge Start() out of the wait-receive.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

So if connClosed writes to cfgErrc while Configure is running, the deferred send in Configure would block forever and leak a goroutine?

Hmm, I think it shouldn't. The channel is buffered with a capacity of 1. We always read/receive 1 error, sent from Configure() or connClosed(), whichever comes first, and we try to send at most 2. So if we send 2, then 1 stays buffered in the channel, which should not be a problem either, because if the stub is ever re-Start()ed (so we try to go through this again), it creates a new cfgErrC channel.

What if we make the send in Configure non-blocking as well? That would ensure whichever function gets there first wins.

defer func() {
	select {
	case stub.cfgErrC <- retErr:
	default:
	}
}()

What do you think?

Yes, I think that is a good idea. I updated the PR accordingly.

@mikebrow mikebrow 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.

LGTM

@klihub klihub force-pushed the fixes/cancel-start-on-early-conn-loss branch from bf9edb1 to d8515b0 Compare October 10, 2025 08:26
If our connection gets closed before we had a chance to get
Configure()'d by the runtime, cancel Start()'s wait for the
result by letting it know about the failure.

Signed-off-by: Krisztian Litkey <krisztian.litkey@intel.com>
@klihub klihub force-pushed the fixes/cancel-start-on-early-conn-loss branch from d8515b0 to 1681e81 Compare October 10, 2025 14:56
@klihub klihub marked this pull request as draft November 24, 2025 19:44
@klihub

klihub commented Feb 26, 2026

Copy link
Copy Markdown
Member Author

Closing this for now, will need to take a closer look.

@klihub klihub closed this Feb 26, 2026
wevans-ant added a commit to wevans-ant/nri that referenced this pull request Jul 3, 2026
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.

Add a regression test that lets Register succeed, blocks the plugin's
Configure handler so cfgErrC is never written, then has the runtime time
out and close the connection; without this fix Start() never returns.

Supersedes containerd#238.

Signed-off-by: Chris Evans <wevans@anthropic.com>
wevans-ant added a commit to wevans-ant/nri that referenced this pull request Jul 4, 2026
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.

Add a regression test that lets Register succeed, blocks the plugin's
Configure handler so cfgErrC is never written, then has the runtime time
out and close the connection; without this fix Start() never returns.

Supersedes containerd#238.

Signed-off-by: Chris Evans <wevans@anthropic.com>
wevans-ant added a commit to wevans-ant/nri that referenced this pull request Jul 4, 2026
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 specs: 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>
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