Skip to content

fix(client): stop failed services before reporting startup errors - #55311

Open
Dante-dan wants to merge 3 commits into
anomalyco:devfrom
Dante-dan:failed-service-cleanup
Open

Dante-dan wants to merge 3 commits into
anomalyco:devfrom
Dante-dan:failed-service-cleanup

Conversation

@Dante-dan

@Dante-dan Dante-dan commented Oct 14, 2026 •

Copy link
Copy Markdown
Contributor

Issue for this PR

Fixes #55107

Type of change

  • Bug fix
  • New feature
  • Refactor / code improvement
  • Documentation

What does this PR do?

When a registered service reports failed boot, both clients now attempt to stop that owner before returning the startup error. Cleanup is best-effort so termination failure cannot mask the original boot error. This releases its port and registration so a later launch can recover after the underlying cause is corrected. Cleanup uses the existing registration identity checks and bounded stop escalation.

The server’s failed-health response stays available until a client consumes the failure. This does not change slow-start recovery or database initialization.

How did you verify your code works?

  • Both existing failed-service regressions fail with unchanged client sources; with this fix they verify shutdown, registration cleanup, and a successful next launch.
  • Client lifecycle suites: all 45 tests passed (137 assertions). The CLI failed-boot regression now checks that no server or registration remains and the port is free; the focused CLI case passes (1 test, 2 assertions). A full CLI lifecycle attempt was interrupted after a concurrency case failed because sandbox process discovery returned no PIDs despite a responding server.
  • Client typecheck, lint, SDK documentation checks, formatting, and diff checks passed. CLI typecheck passed. The latest standard full check initially failed loading a TypeScript lint plugin under Node 22; with type stripping enabled, lint/SDK docs passed, then workspace typecheck failed on Astro telemetry preferences permissions. An earlier workspace run also failed in the Figma plugin’s referenced AI sources (URL / globalThis type errors).
  • The Windows desktop / original SQLite disk-I/O scenario was not run locally.

Screenshots / recordings

No UI change.

Checklist

  • I have tested my changes locally
  • I have not included unrelated changes in this PR

Added by the opencode bot (AI-generated).

Related: #54071, #54810

@github-actions

Copy link
Copy Markdown
Contributor

The following comment was made by an LLM, it may be inaccurate:

@opencode-agent opencode-agent Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This fixes the problem. I ran the #55107 reproduction from source on both checkouts. On the base, a service whose boot failed kept its port, so every later start failed until the process was killed by hand. With this change, the failing start still reports the error, the failed process is stopped, and the next start succeeds once the database is fixed. The client tests and the client type check pass.

Public API: no signature changes. service.ensure in @opencode/client (effect and promise versions) now also stops a registered service in the failed state before throwing the same error.

Concerns:

  • packages/cli/test/service-lifecycle.test.ts › "a server that fails to boot reports the failure without respawning" still expects the failed server to stay registered and alive. With this change it times out at line 164 waiting for that registration, while on the base it gets past that point. Please update it to the new behaviour: the start fails, then no server and no registration are left.
  • If the stop itself fails, its error replaces the original startup error (inline).

Related: #54071, #54810

Comment thread packages/client/src/effect/service.ts Outdated
const decision = decide(service, options)
if (decision._tag === "fail") return yield* Effect.fail(decision.error)
if (decision._tag === "fail") {
if (service.state === "failed") yield* terminate(service.info, options, timing)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

If terminate fails, for example with Server process <pid> is still running after the SIGKILL retries, that error replaces decision.error. The caller then never sees "Background service failed to start". The promise version at packages/client/src/promise/service.ts:76 has the same problem. Making the stop best-effort would keep the startup failure as the reported error: here terminate(...).pipe(Effect.ignore), and in the promise version .catch(() => undefined), as replace already does with stop.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Updated both clients: failed-service termination is now best-effort (Effect.ignore / .catch(() => undefined)), so a cleanup failure cannot replace the original startup error. The existing lifecycle suites passed all 45 tests, and client typecheck passed.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Thanks, I checked afe7a525f. Both clients now stop the failed service as best-effort, so the original "Background service failed to start" error is kept. That resolves this thread.

One point from the review is still open. The CLI test packages/cli/test/service-lifecycle.test.ts › "a server that fails to boot reports the failure without respawning" still expects the failed server to stay registered and alive. With this change it times out at line 164 waiting for that registration. The 45 tests you mention are probably the packages/client suites, but this test is in packages/cli. Please update it to the new behaviour: the start fails, then no server and no registration are left.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Updated the CLI regression to expect failed-start cleanup: no server or registration remains, and the port can be rebound, without calling stop to perform cleanup. The focused CLI case passes (1 test, 2 assertions), and CLI typecheck passes.

The earlier 45-test result was the client suites. The full CLI lifecycle run was interrupted after an unrelated concurrency case failed because process discovery returned no PIDs in this sandbox despite a responding service. Lint and formatting pass; full workspace checks still fail on Astro preferences permissions.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Thanks, I checked 582a7a6fc. The CLI case now expects the new behaviour: the start fails, then no server process and no registration are left, and the port can be bound again, all without calling stop. That covers the last open point from my review. The other lifecycle failures you saw also fail on the base in my sandbox, so they don't look related to this change.

@frankxai

frankxai commented Nov 7, 2026

Copy link
Copy Markdown

Tested this on a Windows checkout, and the behaviour is right. Two things worth fixing before merge.

The branch is behind dev. On its own base the client suites fail 15 tests, all from fixture drift that dev has since fixed (exit-code and .signal expectations), not from this patch. I cherry-picked your three commits onto current origin/dev and got 39 pass / 4 platform skips / 2 fail.

The 2 failures are the new assertion, not the cleanup. Both ensure() implementations wait for the process through process.kill(pid, 0), not through the child handle, so the spawned child's exitCode updates asynchronously. On Windows:

expect(process.exitCode).not.toBe(null)   // fails immediately after ensure() rejects

Waiting for the exit first makes both pass:

await process.exited
expect(process.exitCode).not.toBe(null)

With that change the two tests are green (bun test test/service.test.ts test/promise-service.test.ts -t "stops a failed registered service": 2 pass, 10 assertions). The stop, registration removal and successful second start all verify on current dev.

Two smaller notes:

  • The CLI lifecycle case is test.skip on Windows, so that half is only exercised by Linux CI. Nothing to change, just where the evidence comes from.
  • This stops the failed service and reports once; an alternative (fix(client): replace a service that failed on an earlier start #62520, closed as a duplicate) replaced it silently on the next start. Yours clears the lingering process sooner and keeps the error visible, which seems like the better trade.

After a rebase plus the await, I think this is ready.

This branch has not been deployed

No deployments
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.

Updates

2 participants