Skip to content

[BUG] A thrown prewarm aborts the whole cachekeep tick and blocks every later target permanently #150

Description

@iceteaSA

sendPrewarm() issues the prewarm with signal: AbortSignal.timeout(...). On a timeout or network error fetch throws rather than returning a result, and nothing between that throw and start()'s .catch() handles it:

  • sendPrewarm()await fetchImpl(...), no try/catch
  • prewarm()await this.sendPrewarm(target), no try/catch
  • tick()await this.prewarm(target, now) inside for (const target of this.targets.values()), no try/catch
  • start().catch() logs cachekeep tick failed

So one throwing target aborts the entire tick. Reproduced with an injected fetchImpl that throws for target A only, with A, B, C all tracked and due:

tick THREW: TimeoutError: The operation timed out.
prewarm attempts: ["A"]        <- B and C never attempted
tick +1m attempts: ["A"]
tick +2m attempts: ["A"]
tick +3m attempts: ["A"]

It never recovers. The failing target's backoff assignment lives in the !result.ok branch, which a throw skips, so cacheExpiresAt is never advanced — A stays first-due and re-aborts the tick on every subsequent tick. B and C are starved until they age out as stale roughly two hours later, at which point cachekeep is silently doing nothing for them. publishTrackedSessions() at the end of tick() is also skipped, so the cross-process lease record stops being refreshed.

This is live, not theoretical — TimeoutError: The operation timed out. is what AbortSignal.timeout produces under Bun 1.3.14, and that exact string appears as cachekeep tick failed in my plugin log.

Worth noting the other two callers of this same throwing path both defend against it, which is what makes the tick loop look like an oversight rather than a decision:

  • warmFableAfterOpus wraps prewarmNow() in try/catch — packages/opencode/src/index.ts:1197-1225
  • PrimeManager.fire wraps its send in try/catch

One trap for whoever fixes it: prewarm() currently uses result.status == null to mean "unbuildable body, delete the tracked session". Mapping a thrown fetch onto that shape would make a transient network blip permanently drop a live session — worse than the current bug. The thrown case needs its own discriminator so it takes the backoff branch, not the delete branch.

I have a fix with tests ready and will open a PR shortly.

Metadata

Metadata

Assignees

No one assigned

    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