Found while fixing #9371 in @objectstack/service-messaging. Same defect, different package — filed rather than fixed, to keep that PR to one defect with one proof.
The shape
Plugin (packages/core/src/types.ts:170) declares exactly one teardown hook:
destroy?(): Promise<void>|void;
ObjectKernel.performShutdown() and LiteKernel.destroy() walk the plugins in reverse and call plugin.destroy(). stop() is not on that interface, so a plugin that spells its teardown stop() is never torn down: await kernel.shutdown() resolves with the plugin's timers still armed.
That is exactly what #9371 was — measured there as 48 further delivery reads/writes in the 80 ms after a resolved shutdown().
This instance
packages/plugins/plugin-reports/src/reports-plugin.ts has an async stop() and 5setInterval/setTimeout sites, and no destroy(). Nothing in the tree calls its stop().
The rest of the class (no timers, so lower stakes — listed for whoever sweeps)
These also expose an async stop() with no destroy(), i.e. a teardown the kernel does not reach. None of them own timers today, so the consequence is limited to whatever else stop() releases:
packages/connectors/connector-openapi/src/connector-openapi-plugin.tspackages/connectors/connector-rest/src/connector-rest-plugin.tspackages/connectors/connector-slack/src/connector-slack-plugin.tspackages/plugins/plugin-approvals/src/approvals-plugin.tspackages/services/service-knowledge/src/knowledge-service-plugin.ts
Why it stays invisible
plugin-reportsunref()s at least some of its timers (as messaging did), so a long-lived host process still exits and nothing complains. The bill lands in test harnesses, where the process is alive throughout teardown — see #9371 for the full mechanism by which that turns into a green suite exiting 1.
Shape of the repair (as applied in #9371)
Move the body to destroy() and keep stop() as a delegating alias — it is public API of an exported class, and removing it would break an embedder who learned to call it directly precisely because the kernel never did.
A guard that fails when a Plugin implementation declares stop() and no destroy() would close the whole class; that is a judgement call for triage, not something this finding asserts.
Found while fixing #9371 in
@objectstack/service-messaging. Same defect, different package — filed rather than fixed, to keep that PR to one defect with one proof.The shape
Plugin(packages/core/src/types.ts:170) declares exactly one teardown hook:ObjectKernel.performShutdown()andLiteKernel.destroy()walk the plugins in reverse and callplugin.destroy().stop()is not on that interface, so a plugin that spells its teardownstop()is never torn down:await kernel.shutdown()resolves with the plugin's timers still armed.That is exactly what #9371 was — measured there as 48 further delivery reads/writes in the 80 ms after a resolved
shutdown().This instance
packages/plugins/plugin-reports/src/reports-plugin.tshas anasync stop()and 5setInterval/setTimeoutsites, and nodestroy(). Nothing in the tree calls itsstop().The rest of the class (no timers, so lower stakes — listed for whoever sweeps)
These also expose an
async stop()with nodestroy(), i.e. a teardown the kernel does not reach. None of them own timers today, so the consequence is limited to whatever elsestop()releases:packages/connectors/connector-openapi/src/connector-openapi-plugin.tspackages/connectors/connector-rest/src/connector-rest-plugin.tspackages/connectors/connector-slack/src/connector-slack-plugin.tspackages/plugins/plugin-approvals/src/approvals-plugin.tspackages/services/service-knowledge/src/knowledge-service-plugin.tsWhy it stays invisible
plugin-reportsunref()s at least some of its timers (as messaging did), so a long-lived host process still exits and nothing complains. The bill lands in test harnesses, where the process is alive throughout teardown — see #9371 for the full mechanism by which that turns into a green suite exiting 1.Shape of the repair (as applied in #9371)
Move the body to
destroy()and keepstop()as a delegating alias — it is public API of an exported class, and removing it would break an embedder who learned to call it directly precisely because the kernel never did.A guard that fails when a
Pluginimplementation declaresstop()and nodestroy()would close the whole class; that is a judgement call for triage, not something this finding asserts.