fix(application): stop backend processes synchronously on shutdown (#10058)

application.New wires a fire-and-forget goroutine that runs
StopAllGRPC + distributed.Shutdown when the app context is cancelled.
Callers (tests, CLI signal handler) cancel the context and then exit
immediately, so the test binary / process can terminate before that
goroutine kills the spawned backend children. go-processmanager sets no
Pdeathsig, so the orphans are reparented to init and survive — leaving
dozens of stray mock-backend processes after an e2e run.

Add Application.Shutdown(), which runs the same cleanup synchronously on
the caller's stack and is idempotent via sync.Once. The context-cancel
goroutine, the CLI signal handler, and the test suites all call it, so
cleanup is deterministic and the duplicated teardown logic collapses to
one place. The async goroutine remains as a safety net for callers that
forget; sync.Once dedupes the double call.

Wire e2e_suite_test and the two mock-backend Contexts in app_test to
call Shutdown in their AfterSuite/AfterEach.

Assisted-by: Claude:claude-opus-4-8 [Claude Code]

Signed-off-by: Richard Palethorpe <io@richiejp.com>
This commit is contained in:
Richard Palethorpe authored and GitHub committed 2026-05-29 11:40:43 +02:00
1 parent e1a782b70f
commit fbcd886a47
5 files changed
+64 -22

No files matched your search

+21 -6
View File
@@ -308,6 +308,11 @@ var _ = Describe("API test", func() {
var cancel context.CancelFunc
var tmpdir string
var modelDir string
// localAIApp captures the Application so AfterEach can synchronously
// stop the spawned gRPC backend processes. application.New cancels
// them asynchronously on context cancel, which races with test-binary
// exit and leaks mock-backend children to init.
var localAIApp *application.Application
commonOpts := []config.AppOption{
config.WithDebug(true),
@@ -736,14 +741,14 @@ parameters:
)
Expect(err).ToNot(HaveOccurred())
application, err := application.New(
localAIApp, err = application.New(
append(commonOpts,
config.WithContext(c),
config.WithSystemState(systemState),
)...)
Expect(err).ToNot(HaveOccurred())
application.ModelLoader().SetExternalBackend("mock-backend", mockBackendPath)
app, err = API(application)
localAIApp.ModelLoader().SetExternalBackend("mock-backend", mockBackendPath)
app, err = API(localAIApp)
Expect(err).ToNot(HaveOccurred())
go func() {
if err := app.Start("127.0.0.1:9090"); err != nil && err != http.ErrServerClosed {
@@ -765,6 +770,11 @@ parameters:
}, "2m").ShouldNot(HaveOccurred())
})
AfterEach(func() {
// Synchronous shutdown — context-cancel cleanup is async and races
// test-binary exit, orphaning mock-backend children to init.
if localAIApp != nil {
_ = localAIApp.Shutdown()
}
cancel()
if app != nil {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
@@ -976,15 +986,15 @@ parameters:
)
Expect(err).ToNot(HaveOccurred())
application, err := application.New(
localAIApp, err = application.New(
append(commonOpts,
config.WithContext(c),
config.WithSystemState(systemState),
config.WithConfigFile(configFile))...,
)
Expect(err).ToNot(HaveOccurred())
application.ModelLoader().SetExternalBackend("mock-backend", mockBackendPath)
app, err = API(application)
localAIApp.ModelLoader().SetExternalBackend("mock-backend", mockBackendPath)
app, err = API(localAIApp)
Expect(err).ToNot(HaveOccurred())
go func() {
@@ -1005,6 +1015,11 @@ parameters:
}, "2m").ShouldNot(HaveOccurred())
})
AfterEach(func() {
// Synchronous shutdown — context-cancel cleanup is async and races
// test-binary exit, orphaning mock-backend children to init.
if localAIApp != nil {
_ = localAIApp.Shutdown()
}
cancel()
if app != nil {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)