Repository navigation
Conversation
|
Hey! Your PR title Please update it to start with one of:
Where See CONTRIBUTING.md for details. |
|
The following comment was made by an LLM, it may be inaccurate: |
There was a problem hiding this comment.
Thanks for measuring the marks so carefully. As written, though, this change can lose migrated settings for users who still have legacy .dat files, so I don't think it can go in as is. I couldn't run the desktop app here (headless Linux), so this comes from reading the code. The storage tests (29 of 29) and bun run typecheck in packages/desktop pass.
The early snapshot's legacy check only protects the window until the layer is up. Once importLegacyStores is forked, the layer finishes straight away, installs setStorageSnapshotProvider and opens the IPC port, and nothing waits for the import to commit. So the snapshot and StorageItems can serve empty namespaces, which the renderer then keeps for its lifetime. Worse, any write the renderer flushes before the import's transaction creates a row that onConflictDoNothing keeps, and the import then deletes the legacy file, so the user's old value is lost. Details are inline.
Public API: unchanged.
| : Effect.logInfo("imported legacy store files", { imported: result.imported, files: result.removed }), | ||
| ), | ||
| Effect.catch((error) => Effect.logWarning("failed to import legacy store files", { error })), | ||
| Effect.forkScoped, |
There was a problem hiding this comment.
Forking here lets the rest of this layer run before the import commits. setStorageSnapshotProvider is installed a few lines below, and from then on the snapshot handler no longer goes through readWindowSnapshot's "legacy files present → empty storage" guard. The IPC port, and with it StorageItems/StorageUpdate, is also handed out after the layers. So on the first launch with legacy .dat files:
StorageItems/ snapshots can return empty namespaces, and the renderer loads each namespace only once, so it keeps that empty state.- Any
StorageUpdatethat flushes before the import's transaction creates rows that the import'sonConflictDoNothingkeeps.importLegacyStoresthen deletes the.datfile, so the user's old value is lost for good.
If the import has to leave the layer's critical path, the storage handlers and the snapshot provider need to wait for it, e.g. by awaiting a Deferred before answering StorageItems/StorageUpdate and before swapping the snapshot provider. Another option is a cheap synchronous check, so the import only blocks when legacy files actually exist. When there are none, which is the common case, the cost is just the readDirectory.
|
Fixed — you were right, and the race was exactly as you described. The import stays forked; a
Regression test: It needs an Electron stub, so it adds
|
95ef0af to
0db0902
Compare
ab0f886 to
fee6124
Compare
There was a problem hiding this comment.
The new commit fixes the race from my earlier comment: the snapshot provider and StorageItems/StorageUpdate/StorageClear now wait for the import, and the Deferred is completed in ensuring, so a failed import doesn't block them forever. I couldn't run the desktop app here (headless Linux). The full packages/desktop suite passes, typecheck is clean, and the new test fails against the previous ungated commit and passes with the gate.
Public API: unchanged.
One path is still not gated: the extension host gets storage.state directly (inline).
packages/desktop/src/main/storage/index.ts:68:storage.stateis still handed out ungated here, and the extension host uses it directly (extension/index.tspasses it tocreateHost, andextension/storage.tsreads and writes through it).gui-extensions/src/browser/restore.tsmigrates from{ state: ["opencode.browser.dat", key] }, which is one of the legacy files this import copies. If the host loads before the import commits,read()returns null and caches it inentry.jsonfor the rest of the process. A laterwrite()then overwrites the imported value, so the comment above ("every read and write of the state table waits onready") doesn't hold yet. Making the host's loading step wait forstorage.ready(e.g. adding it to thePromise.allinhost()) would close this.
fee6124 to
bed5f50
Compare
There was a problem hiding this comment.
The new push only rebases the branch; both commits are unchanged. The race from my first review is fixed by the gating commit (the snapshot provider and the storage IPC handlers wait on ready). The point from my last review about the extension host getting storage.state without waiting on ready is still open. I checked this on the rebased head: the packages/desktop tests pass and tsc -b is clean. I couldn't run the desktop app here (headless Linux).
Public API: unchanged.
There was a problem hiding this comment.
The new commit fixes the point from my last review: host() now waits for storage.ready, so the host's stores can't read state and cache an empty value before the import commits. I couldn't run the desktop app here (headless Linux). The full packages/desktop suite passes, tsc -b is clean, and the new test fails without the fix and passes with it.
Public API: unchanged.
The one thing left is the new test: it is large and depends on timing for a one-line change (inline).
| ) | ||
| const loading = Context.get(context, Extensions.Service).host() | ||
| const loadedBeforeReady = yield* Effect.promise(async () => { | ||
| await new Promise((resolve) => setTimeout(resolve, 25)) |
There was a problem hiding this comment.
The fix is one line, but this test is 90 lines and replaces six modules with mock.module stubs (./host, ./assets, ../windows, ../windows/protocol, ../service/desktop-cli, ../lifecycle). In bun test those stubs apply to the whole run, not just this file, so other test files can end up using them depending on the order files run. It also uses a 25ms setTimeout to check that the host hasn't loaded yet, which proves nothing if loading is just slower than 25ms. I'd drop this test so the change stays minimal. If you want to keep it, cut it down to the ordering check without the sleep, e.g. check that loading is still pending after a microtask flush, then resolve ready, then check that createHost ran.
|
Closed in The regression test holds a
|
|
Taken as suggested: the test keeps the ordering check but drops the sleep. After On the mocks: all six are genuinely on
|
There was a problem hiding this comment.
The only change since my last review is the rewritten extension/index.test.ts. The sleep is gone, but the new check can't fail: with the storage.ready wait removed from extension/index.ts (the version from bed5f50ba), the test still passes. Details are inline. The full packages/desktop suite passes and tsc -b is clean. I couldn't run the desktop app here (headless Linux).
Public API: unchanged.
| // One microtask flush settles the import and CLI promises while `ready` still gates the host, so a | ||
| // load here ignored the gate. No sleep, so a slow load cannot fake a pass. | ||
| const beforeReady = yield* Effect.promise(() => | ||
| Promise.race([loading.then(() => "loaded" as const), Promise.resolve("pending" as const)]), |
There was a problem hiding this comment.
Promise.resolve("pending") is already resolved, so it always wins this Promise.race. Loading the host needs at least a dynamic import() and a runPromise, which can't finish in one microtask anyway, so beforeReady is "pending" whether or not the host waits for ready. I put back the extension/index.ts from bed5f50ba (no storage.ready wait), and this test still passes. A check that can fail would be to first let the CLI and import promises settle, e.g. by awaiting import("./host") and desktopCli.resolve in the test, and only then check that createHost hasn't run while ready is still unresolved. Otherwise, as I said before, dropping this test keeps the one-line fix minimal and also gets rid of the six global mock.module stubs.
973be88 to
eb6243a
Compare
There was a problem hiding this comment.
This push only rebases the branch; all four commits are unchanged, so my earlier comments on extension/index.test.ts still apply. On the rebased head the packages/desktop suite passes and tsc -b is clean. I couldn't run the desktop app here (headless Linux).
Public API: unchanged.
eb6243a to
3dc66fb
Compare
|
You were right on both counts, and the second one mattered more than the first. The The test now encodes the contract instead of guessing at timing: the With No sleep, no timer, no test-only parameter in production code. 69 lines. |
There was a problem hiding this comment.
The rewritten test now catches the bug. Reading state throws until the ready effect has actually run, so a host that skips the wait fails no matter how the timing works out. I restored the ungated extension/index.ts and the test fails; with the fix it passes. The full packages/desktop suite passes and tsc -b is clean. I couldn't run the desktop app here (headless Linux).
Public API: unchanged.
3dc66fb to
5f76a6d
Compare
5f76a6d to
2a72d73
Compare
The first window is shown as soon as Electron is ready, and the layers are built on the same thread afterwards. The legacy store import reads and decodes the whole userData directory inside that build, holding the layers (and the IPC port) back. The import is a one-time migration of files the renderer no longer writes, and the early snapshot already handles the pre-import state (window-snapshot.ts checks for legacy files, covered by window-snapshot.test.ts). Fork it so the layers and the port are handed out first. Measured on the dev startup benchmark: the storage interval drops from 72ms to 42-47ms (median of 4 baseline runs vs 2 fixed runs).
Forking the import let the layer finish before it committed: a snapshot or StorageItems could serve an empty namespace the renderer keeps for its lifetime, and a write before the import's transaction would be kept by onConflictDoNothing while the import deleted the legacy file.
Add a Deferred the import completes (through ensuring, so a failure or interrupt still releases it). The snapshot provider and the state handlers await it; the layers and the port are still handed out first.
Regression test: with a legacy .dat present, the snapshot handler must wait for the import. It fails before this change (items: {} instead of the stored value).
2a72d73 to
165c44a
Compare
There was a problem hiding this comment.
This push rebases onto the extension/ → plugin/ rename and squashes the last two commits; the fix is unchanged. The plugin host test now uses four module stubs instead of six and still catches the bug: with the storage.ready wait removed it fails, and with the fix it passes. The full packages/desktop suite passes and tsc -b is clean. I couldn't run the desktop app here (headless Linux).
Public API: unchanged.
|
Automated PR Cleanup Thank you for contributing to opencode. Due to the high volume of PRs from users and AI agents, we periodically close older PRs using automated criteria so maintainers can focus review time on the most active and community-supported contributions. This PR was closed because it matched the following cleanup criteria:
PRs created within the last month are not affected by this cleanup. If you believe this PR was closed incorrectly, or if you are still actively working on it, please leave a comment explaining why it should be reopened. A maintainer can review and reopen it if appropriate. Thanks again for taking the time to contribute. |
Issue for this PR
Closes #54296
Type of change
What does this PR do?
The first window is shown as soon as Electron is ready (
windows/early.ts), and the main bundle and the Effect layers are built on the same thread afterwards. The legacy store import reads and decodes the whole userData directory inside that build, holding the layers — and the IPC port — back.The import is a one-time migration of files the renderer no longer writes, and the early snapshot already handles the pre-import state (
storage/window-snapshot.tschecks for legacy files;window-snapshot.test.tscovers it). This forks it, so the layers and the port are handed out first.How did you verify your code works?
Measured with the repo's dev startup benchmark (
bun run bench:devex), reading the main-process marks (lifecycle/marks.ts).The window→layers block is 280ms (median of 4 runs). Split: main bundle evaluation 152ms, storage 72ms, logging 34ms, onboarding 4ms. Inside storage: sqlite open + migrate 29ms, legacy import 21ms.
Forking the import takes the storage interval from 72ms to 42-47ms (4 baseline runs vs 2 fixed runs, reproducible in the marks).
The benchmark's headline metric (
visibleWindowToHome) does not resolve this: it is dominated by the dev-mode renderer (9.0-14.1s) and the isolated dev service (5.9-14.1s), and itswindowVisiblemilestone is the adoption log after the layers, so the 280ms block sits outside it. Paired deltas were +101ms / −1459ms. The marks are the evidence here.Two assumptions from the issue were measured and did not hold: theme resolution is 1.3ms (not "tens of milliseconds"), and onboarding is 4ms.
Machine (for comparability): AMD Ryzen 5 5600U (6C/12T), 13.8 GB RAM, AMD Radeon integrated graphics (driver 30.0.13044.0) plus a virtual display adapter, 1920×1080 @60Hz, Windows 11 Pro for Workstations build 26300, balanced power plan.
bun test src/main/storage(packages/desktop): 29 passed, 0 failed.Screenshots / recordings
n/a
Checklist