You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
os migrate plan/apply never exit once a host config is composed — the process outlives its own "Graceful shutdown complete" (78 min, measured) #13027
Follow-up to objectstack#12938 / PR objectstack#12952, found while running the
consumer acceptance that PR named (objectstack-ai/cloud#1710).
Measurement
ObjectStack Cloud's migrate-control-db.yml, plane=staging, apply=false, against
the real Neon staging control plane, framework pinned at 15d55fb2430f (carries fc8a33935). Run: objectstack-ai/cloud actions run 33182129326.
The command is os migrate plan inside docker run --rm, cwd /repo/cloud/apps/cloud (an objectstack.config.ts is present, so the host
stack is composed). The CLI finished and said so:
15:03:52.522 17 change(s): 0 safe, 0 needs-confirm, 17 destructive
15:03:52.522 Apply with: os migrate apply (add --allow-destructive for drops / tightenings)
15:03:52.522 4316ms
15:03:52.517 INFO Graceful shutdown started
15:03:52.555 INFO OK Graceful shutdown complete
Then nothing. The next line in the log is 16:22:40 The operation was canceled. — the run was cancelled by hand after 78 minutes. The shell step's
very next statement is an echo, and it never printed, so the shell was still
blocked on that one docker run: the process printed its own graceful-shutdown
line and then did not exit.
Elapsed inside the CLI was 4.3 seconds. Everything after that is a process that
will not die.
Why this is a fix-here defect, not a workflow defect
The composition objectstack#12952 introduced runs host plugins for their
DECLARATIONS: init() runs, start() is replaced with a no-op, destroy() is
forwarded. A host plugin that acquires a resource in init() — a pg pool, an
interval, a socket, a watcher — and releases it from something registered inside start() now has no release path at all, because the thing that would have
registered the release never ran. The event loop stays alive; process.exit is
never reached; the kernel has already reported a clean shutdown, so nothing looks
wrong.
That is a direct consequence of the declaration-only composition and it is
invisible to the CLI's own test suite, which does not assert that the process
exits. The measured shape — 36 host plugins composed, clean shutdown logged,
process alive 78 minutes later — is the first real deployment this composition
has been run against.
Consequence
os migrate plan and os migrate apply never return on a project with a host
config. For cloud's operator workflow that means the job hangs until the job
timeout (six hours by default) after the plan has already been computed and
printed, and any consumer that reads the --json payload — cloud's coverage gate
is one — never gets to run at all.
Suggested shape
Two candidates, not adjudicated here:
Make the declaration-only composition symmetric: if start() is suppressed,
the composition owes the teardown its suppression removed. destroy() is
already forwarded, so the seam exists.
Have plan / apply exit deliberately once the document is written, rather
than relying on the event loop draining. That is a smaller change and it also
covers host code this repo cannot audit, which is the same argument
objectstack#12952 used for declaration-only composition in the first place.
Claim (family member): dispatched together with #13028 on branch claude/issue-13028-composition-seam-family — see the chain-lead claim there for the full serial-constraint and family-gate record. Session: session_71836b57-5db6-459d-ae4d-c6d9d7dd2cc6.
Family member — implemented on branch claude/issue-13028-composition-seam-family together with #13028. Draft PR: #13057
The os-dev-report for this family lives on the chain-lead card: #13028 (comment)
This member in one line: os migrate plan / apply now end the process deliberately once their document is written, after the kernel teardown they already ran, draining stdout and stderr first (a --json payload on a pipe must not truncate) under a bounded wait. Regression pin is packages/cli/test/migrate-plan-exits.e2e.test.ts, which spawns a real child against a fixture whose host plugin arms a ref'd interval in init() and would clear it in the suppressed start(): 2s and exit 0 with the fix, and — ablated, disk-proven — killed at 90053ms without it.
⚠️ One verified consumer-side finding this PR does NOT fix, recorded for the PM: ObjectStack Cloud's lazyPlugin wrapper (packages/service-cloud/src/control-plane-preset.ts) defines init/start/stop and no destroy, while the kernel's only teardown entry point is plugin.destroy() — nothing in packages/core/src ever calls plugin.stop(). So roughly twenty control-plane plugins release nothing on shutdown in every cloud boot, os serve included. That is the cloud-side root of this card; the framework half landed here makes the command exit regardless of what the host left running.
Activity
os-zhuang commentedon Aug 28, 2026
Claim (family member): dispatched together with #13028 on branch
claude/issue-13028-composition-seam-family— see the chain-lead claim there for the full serial-constraint and family-gate record. Session:session_71836b57-5db6-459d-ae4d-c6d9d7dd2cc6.hotlong commentedon Aug 29, 2026
Family member — implemented on branch
claude/issue-13028-composition-seam-familytogether with #13028. Draft PR: #13057The
os-dev-reportfor this family lives on the chain-lead card: #13028 (comment)This member in one line:
os migrate plan/applynow end the process deliberately once their document is written, after the kernel teardown they already ran, draining stdout and stderr first (a--jsonpayload on a pipe must not truncate) under a bounded wait. Regression pin ispackages/cli/test/migrate-plan-exits.e2e.test.ts, which spawns a real child against a fixture whose host plugin arms a ref'd interval ininit()and would clear it in the suppressedstart(): 2s and exit 0 with the fix, and — ablated, disk-proven — killed at 90053ms without it.lazyPluginwrapper (packages/service-cloud/src/control-plane-preset.ts) definesinit/start/stopand nodestroy, while the kernel's only teardown entry point isplugin.destroy()— nothing inpackages/core/srcever callsplugin.stop(). So roughly twenty control-plane plugins release nothing on shutdown in every cloud boot,os serveincluded. That is the cloud-side root of this card; the framework half landed here makes the command exit regardless of what the host left running.Generated by Claude Code