Skip to content

Harden the goal state writes against transient rename locks on Windows - #98

Open
ggrabarz wants to merge 4 commits into
prevalentWare:mainfrom
ggrabarz:pr/02-goal-state-rename-locks
Open

ggrabarz wants to merge 4 commits into
prevalentWare:mainfrom
ggrabarz:pr/02-goal-state-rename-locks

Conversation

@ggrabarz

@ggrabarz ggrabarz commented Oct 21, 2026 •

Copy link
Copy Markdown

Fixes in this pull request

  • fix/windows-rename-lock-hardening - On Windows a rename over a file that another process still has open fails with EPERM. The goal state write now writes a temp file and renames it over the state file with a retry, so a transient lock does not lose the write.

Why

The plugin runs in more than one process (the server and the TUI), so the state file is genuinely contended. The failure showed up in the host log as an EPERM on the goal state file and left the state unchanged, which reads as a lost goal.

Verification

  • The full local gate passes on this branch.
  • A test covers the write against a file that is held open, and covers the retry.

This is the second pull request of a stack. Merge the earlier ones first; once they are merged this diff is only the fix above.


View with [code]smith Autofix with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need.

Grzegorz Grabarz and others added 4 commits October 21, 2026 11:29
The repository stores every text file with LF endings, but nothing said so.
A commit made on a machine whose core.autocrlf is false therefore stored
whatever the editor happened to write, and a file saved with CRLF endings
landed in the commit as CRLF. The next edit to that file then rewrote every
line of it, which turned a small fix into a whole-file diff and hid the real
change.

A .gitattributes file pins the convention, so the line endings of a file no
longer depend on the settings of the machine that makes the commit.
The plugin declared @opencode-ai/plugin as a runtime dependency and the
host-provided @opentui/solid and solid-js peers as required peers. npm
auto-installs required peers, so every host that installed this plugin
also installed @opentui/core, typescript, web-tree-sitter, @babel and a
second Effect runtime. The OpenCode log reported the resulting install as
a stalled plugin preparation step:

  message="plugin operation stalled" component=plugin stage=prepare
  elapsedMs=5002 target=@prevalentware/opencode-goal-plugin install=true

@opencode-ai/plugin is only imported with `import type`, so it never
reaches the built bundle: dist/server.js imports zod, effect-goal-state
and node builtins only. The TUI peers are supplied by the OpenCode host at
runtime, which is why @opencode-ai/plugin itself declares the equivalent
peers as optional.

Measured on this repository by packing the published files and installing
each variant into an isolated project:

  before: 10.20s, 157.81 MB, 11928 files
  after:   5.94s,  33.48 MB,  4589 files

Adds test/dependencies.test.ts to keep the runtime imports and the declared
dependencies in sync, and to assert that the host-provided peers stay
optional so the install footprint cannot silently grow back.
The host resolves the plugin entrypoint through the exports map of the
installed package, and it falls back to the main entry when the subpath does
not resolve. The subpaths only answered to the "import" condition and the
package had no main entry at all, so a host that resolved them under another
condition found no entrypoint: the host reports that as "Plugin entrypoint not
found", leaves the location without a fully activated plugin set, and fails
the commands that need one with "Location plugins did not finish
activating".

Both subpaths now carry a default condition and the package has a main entry
that points at the bundle, so the entrypoint resolves whichever condition the
host resolves it under.
The goal plugin replaces its state file through a same-directory temp file
and a rename. On Windows that rename fails with EPERM/EACCES/EBUSY while
an antivirus scanner, a search indexer, or a concurrent reader still holds
the target open, and those locks routinely outlive a couple of hundred
milliseconds.

The retry budget was three attempts with a 60ms total backoff, so a
transient lock surfaced as a failed goal state write and the goal tools
threw EPERM to the caller. The budget is now ten attempts with a capped
exponential backoff (~2.6s worst case, still one rename when uncontended).

Also fixes two Windows-only test failures that the upstream suite carried:
the state-recovery log assertion compared a raw path against a JSON
stringified payload (JSON escaping doubles the path separators), and the
TUI test helpers replaced the state file with a bare rename that has no
retry.
@ggrabarz
ggrabarz force-pushed the pr/02-goal-state-rename-locks branch from 733903f to a1d4fb2 Compare October 21, 2026 09:47

@danyel117 danyel117 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The retry logic is scoped, but its new regression test injects EPERM through a mock and the repository CI runs only on Ubuntu. It proves the retry loop, not the stated Windows file-lock behavior. Please document that distinction in this PR and provide a Windows reproduction or targeted Windows validation before treating the platform claim as verified. The body also needs the model/harness or manual-work disclosure required by CONTRIBUTING.md. CI has been enabled and is still running.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants