Repository navigation
fix(server): run onInitialized callbacks registered after initialization - #1019
Conversation
…ion (modelcontextprotocol#920) ServerSession.onInitialized composed callbacks into a plain var, so a callback registered after notifications/initialized was never invoked, and concurrent registration could drop callbacks. Keep pending callbacks in an AtomicRef<PersistentList?>. The initialized handler swaps it to null and runs the list in registration order; registration after that runs the callback immediately. Each callback now runs at most once. Fixes modelcontextprotocol#920
1fecc81 to
51124e6
Compare
There was a problem hiding this comment.
Requesting changes for the blocking finding inline — happy to take another look once it's addressed!
Produced by Air Automations. Name: Code review / Run: https://air.jetbrains.cloud/org/05cf1a7f-6ab5-713b-abd3-29d0c8a05e2d/automations/409567ec-d3a7-40ac-ba95-8518ec577ac2?run=10a56e9c-9299-4705-9f96-488644fbf0ab
| } | ||
| setNotificationHandler<InitializedNotification>(Defined.NotificationsInitialized) { | ||
| _onInitialized() | ||
| pendingInitializedCallbacks.getAndSet(null)?.forEach { callback -> callback() } |
There was a problem hiding this comment.
Blocking: the flush loop has no per-callback error isolation, unlike the immediate path added in onInitialized below, and the callbacks it skips can never be replayed because the list has already been swapped for null.
Failure scenario: two components register onInitialized before the handshake and the first one throws. forEach aborts; Protocol.onNotification catches the exception and reports it to onError, but every callback registered after the throwing one never runs, and nothing will ever run it. So one misbehaving callback (e.g. from a library) silently disables the application's readiness callback — the same silent "never runs" failure mode as #920. Verified locally against this branch: registering { calls += "first" }, { error("boom") }, { calls += "third" } and then completing the handshake yields calls == [first] with boom reported to onError.
Suggested fix: route both paths through one guarded helper, so a failing callback cannot drop the others:
private fun runInitializedCallback(block: () -> Unit) {
try {
block()
} catch (e: CancellationException) {
throw e
} catch (cause: Throwable) {
logger.error(cause) { "Error in onInitialized callback" }
onError(cause)
}
}and call it from this forEach as well as from onInitialized.
…s; improve related test coverage.
There was a problem hiding this comment.
Nice work — the callback isolation now covers both paths, so this looks good to merge.
Produced by Air Automations. Name: Code review / Run: https://air.jetbrains.cloud/org/05cf1a7f-6ab5-713b-abd3-29d0c8a05e2d/automations/409567ec-d3a7-40ac-ba95-8518ec577ac2?run=baa4e459-5f75-4c57-8c1f-ba008cb2336d
Fixes #920
ServerSession.onInitializedput callbacks together in a plainvar, and thenotifications/initializedhandler invoked it. That caused two problems:notifications/initialized, it never runs.varis read and written without synchronization. WhenonInitializedis called from several threads while the handshake is in progress, two registrations can read the same old value, and one of the callbacks is then dropped.Pending callbacks are now stored in an
AtomicRef<PersistentList<() -> Unit>?>, using the same atomicfu + kotlinx-collections-immutable pattern asFeatureRegistry:onInitializedadds the callback withgetAndUpdate. If the ref was alreadynull, initialization has finished, so the callback runs right away on the caller's thread.getAndSet(null)and runs the list it gets back once, in registration order.Each callback now runs at most once, and callbacks pending when
notifications/initializedarrives run in registration order. (A callback never runs if the client doesn't sendnotifications/initialized, and if one throws, the ones after it don't run.) There is one behavior change: before, a duplicatenotifications/initializedran all callbacks again. Now it runs nothing. No public API changes (apiCheckpasses).This follows up #938, which targeted the same issue and was closed by its author.
Tests (in
ServerSessionInitializeTest):should run onInitialized callback registered after initialization: a callback registered after the handshake runs. Before the fix it failed with[early]instead of[early, late].should run every onInitialized callback registered concurrently: 200 callbacks are registered concurrently onDispatchers.Defaultbefore the handshake, and each must run once. Before the fix it failed in 5 out of 5 runs, with 2 to 16 callbacks dropped.I haven't run the full
integration-testsuite (the TypeScript interop tests) or the non-JVM targets.I used Claude Code to write this change and the PR description.