Problem
#555 reported that when azd app run --detach died, the services it had already started kept running and self-restarting, holding ports, with no handle to kill them.
PR #565 fixed the five causes of the manager dying and of it leaving no handle behind, which removes the reported trigger. It did not add OS-enforced teardown, so a manager that dies some other way (SIGKILL, power loss, an unhandled panic) can still orphan its services.
Why it was deferred
An architecture review of the obvious fix (attach a kill-on-close Job Object from the shared service.StartService path) found three blockers:
- Ownership is wrong at the shared layer.
StartService is also used by the short-lived azd app start, which returns immediately by design. A job owned by that process would kill the services the moment the command exits. The same applies to MCP-started services, which would become tied to the MCP host's lifetime.
- Post-start attachment races. Wrapper processes such as npm and mise spawn grandchildren before an
AssignProcessToJobObject call can land, so those grandchildren escape the job. Doing it correctly needs CREATE_SUSPENDED, then assign, then resume.
- Coverage gap. A Job Object does nothing for Aspire, containers, or Unix, so it is a partial answer that still needs a second mechanism.
Doing this properly means an explicitly owned process group threaded through orchestration, which is a new subsystem in the service start path used by every command. That did not belong in a bug-fix PR.
Proposed direction
- Introduce an explicit service group owned by the orchestrator, not by
StartService.
- Only the long-lived
run manager takes ownership; start and MCP opt out.
- Windows:
CREATE_SUSPENDED, then AssignProcessToJobObject, then ResumeThread, so wrapper grandchildren cannot escape.
- Unix: process group plus
killpg, building on the existing setsid isolation.
- Decide explicitly what should happen for Aspire and container-hosted services.
Context
Problem
#555 reported that when
azd app run --detachdied, the services it had already started kept running and self-restarting, holding ports, with no handle to kill them.PR #565 fixed the five causes of the manager dying and of it leaving no handle behind, which removes the reported trigger. It did not add OS-enforced teardown, so a manager that dies some other way (SIGKILL, power loss, an unhandled panic) can still orphan its services.
Why it was deferred
An architecture review of the obvious fix (attach a kill-on-close Job Object from the shared
service.StartServicepath) found three blockers:StartServiceis also used by the short-livedazd app start, which returns immediately by design. A job owned by that process would kill the services the moment the command exits. The same applies to MCP-started services, which would become tied to the MCP host's lifetime.AssignProcessToJobObjectcall can land, so those grandchildren escape the job. Doing it correctly needsCREATE_SUSPENDED, then assign, then resume.Doing this properly means an explicitly owned process group threaded through orchestration, which is a new subsystem in the service start path used by every command. That did not belong in a bug-fix PR.
Proposed direction
StartService.runmanager takes ownership;startand MCP opt out.CREATE_SUSPENDED, thenAssignProcessToJobObject, thenResumeThread, so wrapper grandchildren cannot escape.killpg, building on the existingsetsidisolation.Context
docs/specs/detach-port-testcmd/spec.md(Non-Goals)