Summary
The built-in /ping handler can dereference a null pointer while the service is shutting down, killing the process with SIGSEGV.
ComponentContextImpl::IsAnyComponentInFatalState() iterates components_ and calls GetComponent()->GetComponentHealth() without checking the result:
// core/src/components/component_context_impl.cpp
for (const auto& comp : components_) {
switch (comp->GetComponent()->GetComponentHealth()) {
But GetComponent() legitimately returns nullptr for a component that has already been destroyed:
// core/src/components/component_context_component_info.cpp
RawComponentBase* ComponentInfo::GetComponent() const {
const std::lock_guard lock{mutex_};
return component_.get();
}
ClearComponents() destroys components one by one (resetting each component_) and only afterwards clears the container, so there is a window in which entries are alive but their components are gone. A health check that arrives during that window reads a vtable pointer at address 0.
PingBase::HandleRequestThrow() calls State::IsAnyComponentInFatalState(), which makes the default /ping handler a direct trigger.
Evidence
From the core dump:
Program terminated with signal SIGSEGV, Segmentation fault.
#0 ComponentContextImpl::IsAnyComponentInFatalState ()
at core/src/components/component_context_impl.cpp:278
#1 components::State::IsAnyComponentInFatalState () at core/src/components/state.cpp:28
#2 server::handlers::PingBase::HandleRequestThrow () at core/src/server/handlers/ping.cpp:24
#3 server::handlers::Ping::HandleRequestThrow () at core/src/server/handlers/ping.cpp:44
si_addr = 0x0
rax = 0x0 <- return value of GetComponent()
=> mov (%rax),%rax <- vtable load
Reproduction
Seen under testsuite when the service is stopped while a health check is in flight (the uservice_oneshot fixture makes this easy to hit). All functional tests pass — only the process teardown crashes, which surfaces as ExitCodeError: Service crashed with SIGSEGV signal.
Frequency scales with the number of components, because a wider component set lengthens shutdown and widens the window. In a service with 8 components we measured 4 crashes out of 8 runs; with the fix below, 0 out of 12.
Suggested fix
Skip components that are already gone:
for (const auto& comp : components_) {
- switch (comp->GetComponent()->GetComponentHealth()) {
+ // GetComponent() legitimately returns nullptr for a component that has
+ // already been destroyed by ClearComponents(): a health check (e.g. the
+ // built-in /ping handler) may run concurrently with service shutdown.
+ auto* const component = comp->GetComponent();
+ if (component == nullptr) continue;
+ switch (component->GetComponentHealth()) {
case ComponentHealth::kFatal:
Residual risk
This removes the null dereference, which is the failure we actually observed. A narrower race remains in principle: components_.clear() destroys the ComponentInfo entries themselves while the same loop may be iterating, and a null check cannot help there. Fully closing it would require either synchronising access to components_ or tearing down handlers before components. We did not hit that path in 12 runs after the fix.
Version
Reproduced on the v3.1 tag (commit c9f7772). The same code is present in develop at the time of writing, so the issue applies there as well.
I am happy to open a PR with the fix above if that is preferred.
Summary
The built-in
/pinghandler can dereference a null pointer while the service is shutting down, killing the process withSIGSEGV.ComponentContextImpl::IsAnyComponentInFatalState()iteratescomponents_and callsGetComponent()->GetComponentHealth()without checking the result:But
GetComponent()legitimately returnsnullptrfor a component that has already been destroyed:ClearComponents()destroys components one by one (resetting eachcomponent_) and only afterwards clears the container, so there is a window in which entries are alive but their components are gone. A health check that arrives during that window reads a vtable pointer at address 0.PingBase::HandleRequestThrow()callsState::IsAnyComponentInFatalState(), which makes the default/pinghandler a direct trigger.Evidence
From the core dump:
Reproduction
Seen under
testsuitewhen the service is stopped while a health check is in flight (theuservice_oneshotfixture makes this easy to hit). All functional tests pass — only the process teardown crashes, which surfaces asExitCodeError: Service crashed with SIGSEGV signal.Frequency scales with the number of components, because a wider component set lengthens shutdown and widens the window. In a service with 8 components we measured 4 crashes out of 8 runs; with the fix below, 0 out of 12.
Suggested fix
Skip components that are already gone:
for (const auto& comp : components_) { - switch (comp->GetComponent()->GetComponentHealth()) { + // GetComponent() legitimately returns nullptr for a component that has + // already been destroyed by ClearComponents(): a health check (e.g. the + // built-in /ping handler) may run concurrently with service shutdown. + auto* const component = comp->GetComponent(); + if (component == nullptr) continue; + switch (component->GetComponentHealth()) { case ComponentHealth::kFatal:Residual risk
This removes the null dereference, which is the failure we actually observed. A narrower race remains in principle:
components_.clear()destroys theComponentInfoentries themselves while the same loop may be iterating, and a null check cannot help there. Fully closing it would require either synchronising access tocomponents_or tearing down handlers before components. We did not hit that path in 12 runs after the fix.Version
Reproduced on the
v3.1tag (commitc9f7772). The same code is present indevelopat the time of writing, so the issue applies there as well.I am happy to open a PR with the fix above if that is preferred.