Repository navigation
fix(opencode): recover remote workspace sync after restarts - #1
Conversation
|
This PR doesn't fully meet our contributing guidelines and PR template. What needs to be fixed:
Please edit this PR description to address the above within 2 hours, or it will be automatically closed. If you believe this was flagged incorrectly, please let a maintainer know. |
|
Thanks for your contribution! This PR doesn't have a linked issue. All PRs must reference an existing issue. Please:
See CONTRIBUTING.md for details. |
internetisalie
left a comment
There was a problem hiding this comment.
Reviewed at 363d929 by reading the diff and the surrounding code. Tests were not run by the reviewer.
Root cause confirmed. WorkspaceAdapterRuntime.target wraps the adapter in EffectBridge.fromPromise, and nothing gives it an error type (workspace-routing.ts types it Effect<Target>). So when an adapter rejects during a proxy outage, the error is a defect. The old Effect.catch never caught it, and the listener died with the workspace still showing error. Switching to catchCause and re-raising interrupts is the right fix, and it is applied consistently in all four places.
Tests: both new tests would fail without the fix.
- Stream error: before the fix, the error escaped the loop and the fiber ended, so
connectionsstays at 1. - Target that throws: the error escaped
startSync, so the loop never started. - The 4 s timeouts leave about 3 s of slack over the 1 s backoff.
Findings
- Medium:
errornow means "retrying", but two checks still treat it as final.startSyncrestarts the fiber onexists && status !== "error", andisSyncingmakes the same check.- During an outage the loop sits in
errorthrough each backoff sleep. Any/sync/startcall in that window stops the live loop and starts a new one atattempt = 0. A client that retries/sync/starttherefore cancels out the 2-minute backoff and hits the proxy about once a second. - Nothing distinguishes a loop that is still retrying from one that has given up.
- The obvious fix, keeping a retrying loop in
connecting, would breakcreate(): it waits forerrororconnected, so it would block for the whole outage. One option is to skip the restart instartSyncwhile the loop is alive and still retrying. - The proxy's 503 during an outage is correct as it stands.
- During an outage the loop sits in
- Low: the claim "preserve interruption during shutdown" has no test.
workspace.removeruns at the end of each test but nothing is asserted. A useful test would remove the workspace during the backoff sleep, then check thatisSyncingis false and the adapter'stargetis not called again. - Nit: the new comment in
startSyncsits above theexistscheck but describes the loop, so move it or drop it. Also,startSyncresolves the target and the loop then resolves it again, so each start calls the adapter twice. That predates this PR, but both paths now handle local targets, so the first call could go.
Process
- The compliance bot will auto-close this PR within two hours unless the description uses the PR template sections and links an issue (
Fixes #N).
internetisalie
left a comment
There was a problem hiding this comment.
Follow-up review of 1d8ef99, done by reading the code. Tests were not run by the reviewer. Nothing blocking: the earlier findings are resolved.
| Earlier finding | Status |
|---|---|
Medium: /sync/start restarts a listener that is still retrying and resets its backoff |
Fixed. startSync returns early whenever the workspace's sync fiber already exists. |
| Low: no test that interruption stops the retries | Fixed. The new test removes the workspace during the first backoff sleep and asserts no further target calls. |
| Nit: comment in the wrong place | Fixed. |
| Nit: each start resolves the target twice | Still there. It predates this PR and doesn't need to block it. |
| Process: compliance bot auto-close | Fixed. The description follows the template, Closes #2 points at an open issue, and the compliance and standards checks pass. |
Why the early return is safe:
- A
FiberMapentry disappears when its fiber ends. - The loop now ends in only two cases: it resolves a local target, or it hits a failure it doesn't catch, which the
startSyncwrapper marks aserror. - In both cases the fiber is gone, so a later
/sync/startstill restarts it. - A loop stuck in
connectingorconnectedwas never restarted before this PR either, so nothing that used to recover now doesn't.
Does the new test catch the bug? Yes.
- Repeated start: under
363d929, the secondstartWorkspaceSyncingresolves the target a third time and restarts the loop, soexpect(attempts).toBe(2)fails. - Removal: the loop's second attempt is due at about 1 s. The removal happens at about 0.1 s and the test waits 1.2 s after it, so a loop that kept running would be caught.
- It can't detect a handler that swallows interrupts, because Effect re-interrupts a fiber that has been interrupted. That limits what the test proves; it isn't a problem with the code.
Remaining, not blocking
- Concurrent
startSyncrace: already there, but this PR widened it.- Before: the
FiberMap.hascheck ran after target resolution. Between that check andFiberMap.run, the only other step was a synchronoussetStatus, so the window was essentially zero. - Now: the check runs before
WorkspaceAdapterRuntime.target, so the window lasts as long as the adapter'starget()promise, which can be an HTTP request. - When it bites: it takes two starts for the same workspace close together, such as
create()and a/sync/start. The secondFiberMap.runstops the first loop and replaces it, costing one extra connect. Nothing leaks. - Fix: check the fiber again just before
FiberMap.run.
- Before: the
erroralso means "retrying": already there.- The base loop already set
erroron a failed connection, then slept and retried. - This PR extends that meaning to target-resolution failures and stream failures.
- Inside the code, whether the fiber exists now tells the two cases apart. Anything that reads only the status still can't tell "retrying" from "gave up".
- The base loop already set
|
Follow-up review of
Does the concurrent-start test catch the race? Yes. Without the re-check, the second start's Does the resume test catch the missing resume? Yes. Without Findings
Nothing else is blocking. Once finding 1 is fixed and CI is green, I have no objection to merging. |
|
Follow-up review of
Does the test catch it? Yes. The concurrent-start test now has the second The CI commits
CI at |
Issue for this PR
Closes #2
Type of change
What does this PR do?
A remote workspace can remain in
errorafter its proxy restarts. Adapter target failures can surface as defects, so the old error handler did not retry them. This change retries target resolution and transient connection, history, and event-stream failures while preserving interruption. After reconnecting, OpenCode replays history before reporting the workspace as connected.Repeated
/sync/startcalls leave an active retrying listener alone, preserving its backoff. Removing a workspace interrupts that listener and prevents further target calls.Concurrent starts also recheck for an existing listener after asynchronous target resolution, so a late start does not replace a listener that connected in the meantime.
If that late start's target resolution fails, it leaves the healthy listener's connected status intact. Interrupted startup propagates without a misleading recovery warning.
After the OpenCode process itself restarts, persisted workspace records have no active listeners. Opening the project now resumes its workspace sync after the adapter plugin loads. The per-project scan runs once per process, and an explicit
/sync/startremains available.This PR targets the
devbranch of this fork only. It does not install or modify any running OpenCode or OpenChamber server.The fork's CI workflows select standard GitHub Ubuntu runners because its Blacksmith jobs remained queued without runners. Since this fork is being used as a server, its test matrix runs on Linux only. Other repositories retain the original Linux and Windows Blacksmith matrix. A subprocess regression test now checks for a real error exit rather than a wall-clock limit that flaked under CI load.
How did you verify your code works?
bun test test/control-plane/workspace.test.tsfrompackages/opencode: 40 passed, including a concurrent-start test where the late target fails.bun test test/server/httpapi-workspace-routing.test.tsfrompackages/opencode: 9 passed.bun test test/server/httpapi-instance-context.test.tsfrompackages/opencode: 10 passed, including persisted workspace recovery on project load.bun typecheckfrompackages/opencode: passed./sync/startafter either a proxy process restart or a stable OpenCode container restart. The test verified both workspace connections, retained history, and live prompts through OpenChamber. All fixture containers were purged afterward.Screenshots / recordings
No UI change.
Checklist