Skip to content

Instance bootstrap can hang indefinitely — plugin.init() npm install has no timeout and the per-directory boot promise is cached #33905

Closed as duplicate

Description

@aman-kumar-gw

Version: 1.14.28 (still present in 1.14.51)

Symptom

Every instance-scoped HTTP request (GET /session/:id, GET /session, etc.) hangs forever when the first request triggers a lazy instance bootstrap whose npm dependency install stalls (e.g. slow/unreachable registry on a cold or resumed machine). The server logs status=started for the request and never logs status=completed; no error is thrown. The client eventually gives up on its own timeout (e.g. undici's 300s headersTimeout). Subsequent requests for the same directory hang identically and indefinitely.

Root cause

  • InstanceMiddleware resolves a per-directory instance via Instance.provide({ init: () => AppRuntime.runPromise(InstanceBootstrap), fn: () => next() }) (packages/opencode/src/server/routes/instance/middleware.ts).
  • Instance.provide (packages/opencode/src/project/instance.ts) caches one boot promise per directory:
    const cache = new Map<string, Promise<InstanceContext>>()
    // ...
    let existing = cache.get(directory)
    if (!existing) existing = track(directory, boot({ directory, init, ... }))
    const ctx = await existing   // every request awaits the SAME cached promise
  • InstanceBootstrap (packages/opencode/src/project/bootstrap.ts) awaits plugin.init() inline, before the forked services. plugin.init() → Config.waitForDependencies() joins config dependency fibers that run Npm.add / Npm.install (@opencode-ai/core/npm) to install config-declared plugins and their runtime deps — with no timeout.
  • track() only deletes the cache entry when the promise settles. A boot that rejects clears the entry and the next request retries. A boot that hangs never settles, so it is never evicted or retried, and every subsequent request for that directory parks on the same dead promise permanently.

This produces a permanent, recurring, error-less wedge: the process stays alive (health and other directories' requests are served in ms), but one directory's instance is stuck forever.

Suggested fixes

  • Bound InstanceBootstrap (and the npm install in particular) with a timeout.
  • Evict/replace the cached boot promise when a boot stalls, so a single stuck bootstrap cannot poison all future requests for that directory.
  • Consider moving dependency installs off the request-blocking bootstrap path (install in the background and serve once ready, or fail fast).

Activity

  1. simonklee commented on Jun 25, 2026

    @simonklee
    Member

    Thanks for the details, closing in favor of #32361

  2. willmcginnis commented on Aug 10, 2026

    @willmcginnis

    Flagging a possible mis-triage, plus some fresh static evidence that the mechanism described here is still present in 1.18.3.

    This was closed in favour of #32361, but #32361 is "TUI hangs on black screen at boot (OpenTUI) since v1.17.0 — Ubuntu/Wayland, all terminals, no plugins", which appears to be an unrelated rendering path. The failure described in this issue — plugin.init() awaiting Config.waitForDependencies(), which joins npm install fibers that carry no timeout — looks like a different bug, and closing it here leaves that mechanism untracked.

    An independent read of the shipped 1.18.3 bundle matches this issue's description and adds two details. The custom-tool loader does let Q = (yield* o.directories()).flatMap(io => Zt.scanSync("{tool,tools}/*.{js,ts}", { cwd: io, absolute: !0, dot: !0, symlink: !0 })); if (Q.length) yield* o.waitForDependencies();. First, that wait is gated purely on Q.length — the mere existence of a tool file, before any import is attempted — so the install is awaited even when dependencies are already present and even when the tool file is broken and would never have loaded. Second, the install being joined is forked unconditionally per writable config directory via forkDetach, and the reify it eventually calls runs Arborist with progress: false and no timeout. The result is that a foreground startup can sit behind a silent background install with no progress output and no log line naming the tool subsystem, so the last line before the stall comes from an unrelated subsystem and points debugging in the wrong direction.

    Symptom observed on macOS with 1.18.3: opencode run did not return, and the last line in ~/.local/share/opencode/log/opencode.log was message=init. I want to be clear about the evidence tier — I did not reproduce that on demand. This machine's npm cache was already warm, which is consistent with the mechanism but is not a demonstration of it. Treat the symptom as a credible report and the paragraph above as the static analysis it is.

    A maintainer should be able to force the condition deterministically by making the install unbounded: remove node_modules, package.json and package-lock.json from the config directory, point npm at a registry that does not answer (npm config set registry http://127.0.0.1:9), then run with at least one custom tool file present.

    If this is reopened or refiled, the narrow fix suggested by the above is to move the gate from "a tool file exists" to "a tool file failed to import", and to bound waitForDependencies() with a timeout plus a single log line naming the directory being installed into. Related and adjacent: #37317, #34933 and #35963 cover the neighbouring case where a broken tool file's import error escapes initialization rather than being isolated.

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

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions