Skip to content

cli: service restart can silently reuse an unresponsive incumbent #37795

Open

Description

@kitlangton

Summary

opencode2 service restart can silently fail to stop a temporarily unresponsive managed service, spawn replacement contenders, then reuse the original process when it recovers. The command therefore has a success path where an explicit restart does not replace the service at all.

This occurred while the incumbent service was blocked by cold Location work. The service registration remained present, but its health endpoint did not answer within the two-second probe window. The restart command treated that as no existing service rather than a stop failure.

Environment

  • opencode version: 0.0.0-next-15834
  • OS: Darwin 25.5.0, arm64
  • Terminal: WarpTerminal, TERM=xterm-256color, COLORTERM=truecolor
  • Shell: /bin/zsh
  • Install/channel: global Bun install, next
  • Managed service: one version-matched service, healthy before the cold request and healthy again afterward
  • Active plugins: three local user plugins; names and paths omitted

Reproduction

  1. Start a managed service and record its service instance identity and PID.
  2. Make the service temporarily unable to answer /api/health for more than two seconds without exiting, for example with a synchronous cold-Location operation.
  3. Run opencode2 service restart during that window.
  4. Let the original service recover before a contender replaces it.
  5. Check the service instance identity and PID after the command completes.

The production incident was observed once. The source branch makes the outcome deterministic for a registered service whose health probe times out during stop and recovers during ensure.

Expected Behavior

  • Successful service restart means the registered service was stopped and a different service instance became ready.
  • If the command cannot safely authenticate or stop the incumbent, it should fail with a bounded, actionable error.
  • It should never silently reinterpret an explicit restart as ensure-running.
  • The implementation must continue protecting against stale registrations that point at reused PIDs.
  • Logs should state whether stop was accepted, unsupported, rejected, timed out, escalated, or skipped, and whether ensure reused or started a service.

Actual Behavior

Sanitized incident timeline:

18:01:52.738  incumbent finishes a 53.4-second Location acquisition
18:01:54.079  client event stream attempts to connect
18:01:56.081  connection fails with Transport
18:02:05.397  first serve --service contender starts
18:02:11.808  second contender starts
18:02:12.647  explicit service restart command starts
18:02:24.119  another contender starts
18:02:30.081  another contender starts
18:02:40.841  incumbent finishes delayed post-boot work and becomes responsive
18:03:18       health and service status report the original PID

There is no elected replacement server, shutdown record, or PID change. The original server run continues logging afterward.

The restart handler simply sequences stop and ensure at restart.ts:8-15.

Service.stop calls find and does nothing when find returns undefined:

const existing = yield* find(options)
if (existing !== undefined) yield* kill(existing, options)

find is not a registration lookup. It returns only a service that completes an authenticated health probe. The probe uses a two-second timeout and maps every fetch failure to undefined at service.ts:187-227. Consequently, a registered but temporarily unresponsive incumbent is indistinguishable from no service at the stop boundary.

After the no-op stop, ensure is allowed to reuse any compatible service that becomes ready. If the incumbent recovers while contenders are still losing election, the explicit restart converges back to the same process.

Additional Context

Safety constraint

The existing code correctly avoids blindly signaling a PID from an unauthenticated stale registration. Any fix must preserve that defense. A safe design may need stronger process ownership evidence in the registration, an authenticated out-of-band control path, or an explicit error when ownership cannot be verified. The bug is the silent success, not merely the absence of unconditional SIGKILL.

Distinction from related reports

This report is narrower: the registration and process are valid, the incumbent is temporarily unresponsive, and an explicit restart can return to that same incumbent.

Acceptance criteria

  • A regression test creates a registered service whose authenticated health endpoint times out while its process remains alive.
  • service restart either safely stops that exact instance or exits nonzero with an actionable stop error.
  • service restart never runs ensure after an unconfirmed stop.
  • A successful restart proves that the previous instance exited and that the new registration has a different instance identity and PID.
  • A stale registration pointing at a reused unrelated PID is never signaled.
  • Stop and restart expose distinct outcomes for missing registration, corrupt registration, unreachable health endpoint, rejected stop, unsupported stop, graceful timeout, escalation, and successful replacement.
  • Contender launches and election outcomes are logged without credentials.
  • Passive reconnect and ordinary ensure retain their idempotent reuse semantics; only explicit restart requires replacement.

All URLs, credentials, local paths, instance IDs, and exact PIDs have been omitted.

Activity

  1. added
    bugSomething isn't working
    coreAnything pertaining to core functionality of the application (opencode server stuff)
    on Jul 19, 2026
  2. self-assigned this
    on Jul 19, 2026
  3. kagura-chen commented on Jul 25, 2026

    @kagura-chen
    Contributor

    I'd like to work on this.

    Root cause: stop() relies solely on an authenticated health probe (healthy()) to locate the service. When the incumbent is temporarily unresponsive (health endpoint times out within the 2-second window), healthy() returns None → stop() treats it as "no service" and only removes the registration file. Then start() finds the recovered incumbent and reuses it.

    Proposed fix: In stop(), when healthy() returns None, fall back to checking the registration file. If a registration exists and the registered PID is still alive (verified via process.kill(pid, 0), which is a read-only check), fail with an actionable error instead of silently succeeding. This preserves the safety constraint against signaling stale PIDs — we never SIGTERM an unauthenticated process, we just surface the error.

    The Effect error will propagate naturally through the restart handler, preventing start() from running after an unconfirmed stop.

  4. josephtingiris commented on Jul 26, 2026

    @josephtingiris

    raw logs?

  5. kagura-chen commented on Jul 26, 2026

    @kagura-chen
    Contributor

    Good question — I don't have raw logs from a live reproduction yet. I'll set up a local reproduction this week (force-kill a running service mid-request, then trigger restart) and post the actual stderr/stdout output showing the silent reuse. Will update here once I have them.

  6. rizvancodes commented on Aug 7, 2026

    @rizvancodes

    A controlled follow-up to #37794 reproduced the upstream condition for this failure: aggregate Location acquisition delayed authenticated health responses, with eager per-Location MCP startup acting as a cumulative amplifier.

    The two-second discovery timeout therefore remains capable of treating a live incumbent as unavailable. I added the measurements and evidence boundary to #37794 so the investigation stays consolidated there: #37794 (comment)

  7. vheins commented on Sep 28, 2026

    @vheins

    Still reproducing on v2.0.18 (Linux). Adding a data point because the fix PR (#46016, fix(service): ensure explicit restart never reuses unresponsive incumbent) was closed without being merged, and the same-second contention it addressed is still observable in the shipped binary.

    Evidence from the shipped binary

    In the v2.0.18 binary the ensure path uses:

    requestTimeout : 2000
    retry          : count >= 3 before declaring the service "missing"
    spawnDelay     : 5000
    

    So the liveness window is ~6 s (3 × 2 s). The Background service is unresponsive; recovery cannot preserve persistent terminals path still runs on timedOut after those retries, then stops the incumbent and spawns a replacement.

    Same-second contention (the case #46016 targeted)

    Observed bursts with no effective serialization:

    5 starts @ 05:28:30
    4 starts @ 04:05:22
    3 starts @ 03:25:30
    3 starts @ 02:47:16
    

    Measured impact here (~10 h of log, v2.0.18, Linux, 8 cores)

    Signal Count
    serve --service starts 66
    /api/info → HTTP 503 55
    Background service is unresponsive 17
    event stream disconnected 64
    uncaught exception: write EPIPE 44

    Consistent ~5 s pairing:

    08:37:48  Background service is unresponsive
    08:37:53  cli starting  args=[serve --service]
    

    Probe latency measured while alive

    (Note: the counts above are historical, from before I reduced host load — they reflect the worst period, not current steady state.)

    /api/info normally answers in 36–413 ms, but during load bursts individual probes exceeded the ~6 s window while the process was alive and later recovered — i.e. the "registered but temporarily unresponsive incumbent" case this issue describes.

    Note on the fix

    The distinction this issue asks for (never proceed to ensure() after an unconfirmed stop) is what #46016 implemented and verified. If that PR is still considered correct, re-opening it would address the same-second contention and the silent reuse. If it was closed for a design reason, it would help to state the intended replacement, because the behaviour is still present in released 2.0.18.

    Related: #51216 (same window, Windows/macOS), #37239 (silent respawn loop), #36311 (wedged server at 100 % CPU).

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

Metadata

Metadata

Assignees

Labels

2.0bugSomething isn't workingcoreAnything pertaining to core functionality of the application (opencode server stuff)

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions