Skip to content

os migrate plan/apply never exit once a host config is composed — the process outlives its own "Graceful shutdown complete" (78 min, measured) #13027

Closed

Description

@hotlong

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.

Filed without an assignee.

Activity

  1. added theissue type on Aug 28, 2026
  2. self-assigned this
    on Aug 28, 2026
  3. os-zhuang commented on Aug 28, 2026

    @os-zhuang
    Contributor

    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.

  4. hotlong commented on Aug 29, 2026

    @hotlong
    ContributorAuthor

    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.

    Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

Type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions