Skip to content

fix(process-compose): reset user-stopped services in startService#5814

Open
martijnwalraven wants to merge 1 commit into
supabase:developfrom
martijnwalraven:callboard/start-service-reset
Open

fix(process-compose): reset user-stopped services in startService#5814
martijnwalraven wants to merge 1 commit into
supabase:developfrom
martijnwalraven:callboard/start-service-reset

Conversation

@martijnwalraven

Copy link
Copy Markdown

Problem

stopService marks a service stoppedByUser and drives its state machine to Stopped — a state the transition table only exits via RestartTriggered. startService re-runs the fiber (the process starts and serves) but never resets the state machine, so every subsequent event is dropped by applyEvent: the service stays Stopped for getState/state streams forever, and the unless-stopped restart policy stays disarmed.

This breaks the stop→start cycle packages/stack/README.md documents on the public handle (await stack.stopService("auth"); await stack.startService("auth"); // Restart it (blocks until ready)). No CLI command calls startService today, so this surfaces only for programmatic embedders of the stack/orchestrator.

Fix

Mirror restartService's own remove-reset-run sequence (stopForRestart): remove the fiber-map entry (a terminal fiber not yet pruned would make the onlyIfMissing run a no-op and strand the freshly reset service in Pending), reset the state machine, then run. The reset is gated on terminal state (Stopped/Failed) only — a service still Stopping under an in-flight stopService is not reset out from under that stop; the stop completes to Stopped and a subsequent start heals it.

Tests

  • startService after stopService resets the user-stopped state machine (stop→start reaching Healthy on a second spawn)
  • startService after terminal failure resets and re-runs (failed→start heal)

🤖 Generated with Claude Code

@martijnwalraven martijnwalraven requested a review from a team as a code owner July 7, 2026 05:47

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 8018c45b56

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

const state = yield* SubscriptionRef.get(svc.state);
if (state.status === "Stopped" || state.status === "Failed") {
yield* FiberMap.remove(fibers, d.name);
yield* resetService(d.name);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Restart dependents that are waiting on reset dependencies

When startService is called for a dependent while its existing fiber is still pending on this service's old readiness signal (for example, api waits for db to become healthy, db fails before healthy, then /services/api/start is requested before api times out), this reset replaces db's healthy/started Deferreds but the later onlyIfMissing run skips the already-pending api fiber. That fiber is still awaiting the old Deferred and will not observe the restarted dependency becoming ready, so waitReady(api) can hang or fail on dependency timeout even though the dependency recovered.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in f0fe642: the start-path reset (resetForStart) now replaces a signal Deferred only when it is already resolved, so a waiting dependent stays attached to the same healthy signal the restarted dependency resolves. Regression test: "startService of a failed dependency unblocks a waiting dependent".

const state = yield* SubscriptionRef.get(svc.state);
if (state.status === "Stopped" || state.status === "Failed") {
yield* FiberMap.remove(fibers, d.name);
yield* resetService(d.name);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve stopped signals while a global stop is still waiting

If startService races with stop(), a dependent may have already reached Stopped and resolved its stopped Deferred while one of its dependencies has not yet looked up that signal. Resetting the terminal dependent here recreates stopped; stop() then awaits the fresh unresolved Deferred even though that dependent's stop path already finished, so dependency-ordered shutdown can hang until the global shutdown timeout.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed — and with the resolved-only gating above this can still replace an already-resolved stopped signal during a concurrent stop(), turning the pre-existing start-during-stop wart (an unreaped respawn) into a wait until the shutdown timeout. Start-during-stop needs lifecycle-verb serialization to be right under any semantics; scope proposal in the PR comment (per-service locks shared by start/stop/restart — here or as a follow-up issue).

if (svc !== undefined) {
const state = yield* SubscriptionRef.get(svc.state);
if (state.status === "Stopped" || state.status === "Failed") {
yield* FiberMap.remove(fibers, d.name);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Avoid removing a fiber started by a concurrent start call

When two startService calls overlap for the same stopped/failed service (or for two targets that share that dependency), both can read the old terminal state before either reset completes. The later caller then reaches this removal after the first caller has already reset and inserted a fresh run, so it interrupts the newly-started process and resets its signals again, causing duplicate spawns or a spurious kill from concurrent start requests.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in f0fe642: each service's start step now serializes on a startGate semaphore, so a second concurrent caller observes the reset state and coalesces via onlyIfMissing instead of removing the first's fresh fiber. Regression test: "concurrent startService calls coalesce on one fresh run".

stopService marks a service stoppedByUser and drives its state machine
to Stopped — a state the transition table only exits via
RestartTriggered. startService re-ran the fiber (the process starts
and serves) but never reset, so every subsequent event was dropped by
applyEvent: the service stays Stopped for getState/state streams
forever, the unless-stopped restart policy stays disarmed, and the
stack README's documented stop→start cycle ("Restart it (blocks until
ready)") never worked through startService.

The fix mirrors restartService's remove-reset-run sequence
(stopForRestart): the fiber-map entry is removed before the reset — a
terminal fiber not yet pruned would make the onlyIfMissing run a no-op
and strand the freshly reset service in Pending — then the state
machine resets, then the run proceeds. Gated on terminal state
(Stopped/Failed) only: a service still Stopping under an in-flight
stopService is not reset out from under that stop.

Hardened against the PR review's concurrency findings: the start-path
reset (resetForStart) replaces a signal Deferred only when already
resolved — an unresolved signal can have live waiters (a dependent's
dependency await, a waitReady caller) that replacement would orphan,
while a resolved one has no waiters by definition and must be replaced
so the fresh life can re-attest it. And each service's start step
serializes on a startGate semaphore, so concurrent startService calls
coalesce on one fresh run instead of the second removing the first's
fiber. Regression tests: the stop→start cycle, the failed→start heal,
a probed dependency that failed before ever becoming healthy
unblocking its waiting dependent through the preserved signal, and
concurrent starts spawning exactly once.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@martijnwalraven martijnwalraven force-pushed the callboard/start-service-reset branch from 8018c45 to f0fe642 Compare July 7, 2026 06:13
@martijnwalraven

Copy link
Copy Markdown
Author

Updated the branch with fixes for the review findings — and apologies for the force-push; further changes to this PR will be append-only commits.

  • Reset orphaning waiting dependents: the start-path reset (resetForStart) now replaces a signal Deferred only when it is already resolved. An unresolved signal can have live waiters — exactly this scenario — so a restarted dependency resolves the same object its dependent is awaiting; a resolved signal has no waiters by definition and must be replaced so the fresh life can re-attest it. Regression test: a probed dependency that failed before ever becoming healthy unblocks its waiting dependent.
  • Concurrent starts: each service's start step now serializes on a per-service semaphore, so a second concurrent startService observes the reset state and coalesces on the existing fresh run instead of removing it. Regression test: concurrent starts spawn exactly once, no spurious kill.
  • Start racing stop(): real, and worth fixing as its own unit — lifecycle verbs aren't serialized against each other anywhere today (restartService racing stop() has the same exposure class, and before this PR a start-during-stop respawned a process mid-shutdown that stop() never reaped). Happy to take it on — per-service lifecycle locks shared by start/stop/restart — in this PR if you'd like the scope here, or as a follow-up issue.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant