Skip to content

Agent: keep keystrokes moving while Windows holds the agent's files - #10

Merged
itrycodinganditsbad merged 6 commits into
mainfrom
windows-replace-retry
Oct 20, 2026
Merged

itrycodinganditsbad merged 6 commits into
mainfrom
windows-replace-retry

Conversation

@itrycodinganditsbad

@itrycodinganditsbad itrycodinganditsbad commented Oct 19, 2026 •

Copy link
Copy Markdown
Owner

Main's CI failed the live latency test after the recent merges. On Windows, a keystroke took up to 2.5 s to reach the other machine in the first seconds after a document opened. The agent was writing its own files on the threads that pass keystrokes along. On Windows, those writes wait, or fail, while another program such as a virus scanner or the search indexer holds the file open.

Changes

  • write_atomic retries a rename that Windows refuses with ERROR_ACCESS_DENIED or ERROR_SHARING_VIOLATION, for about 1.5 s.
  • A document's journal is emptied in place, not deleted and made again. Appends retry while another program holds the file, and files being removed are renamed away first. A holder that doesn't share a file for deletion used to make clear_journal fail, so the store failed to save.
  • status.json is written on its own thread rather than on the run loop that relays updates between the agent and the apps. The first status (before the local API answers) and the last (before the data folder lock is released) are still written by the loop itself, so status and a second run read what they always did.
  • A live document's routine whole-file write no longer waits for a held file: it retries at the next 2-second write. An ack still means the change is in the journal on disk.
  • The latency test prints every keystroke in order, and bounds the median and the 95th percentile instead of only the worst case. It no longer starts typing before the first machine's document is online. The two latency tests take turns, and a keystroke more than 5 s late is waited for and reported as late, so a late keystroke can't pass for a lost one. In 60 runs under heavy load, none was lost.
  • The misbehaving-app test gives the app that never reads a 64 KiB receive buffer, so the agent's send blocks on macOS too (its loopback buffers can grow past what the test sends).

Tests

  • Windows tests hold the agents' status.json, document and journal files open the way a scanner does:
    • Before: median 33 ms, 95th percentile 2.04 s, single keystrokes up to 2.3 s.
    • After: about 34 ms at worst.
  • Both latency tests, 10 runs each:
    • Windows: 20 of 20 pass, worst keystroke 33–35 ms.
    • Linux: 10 of 10 pass, worst 13–18 ms.
  • The agent's tests pass on Windows (312) and Linux (327), and cargo xtask ci passes on Linux.

The "Access is denied" line in the earlier CI logs was a side effect: the failed test deleted its temporary folders while the agents still ran. The consistent ~400 ms worst case on Linux and macOS CI didn't reproduce locally, so it's not explained yet. The test now names the slow keystroke if it shows up again.

🤖 Generated with Claude Code

https://claude.ai/code/session_01RC1oqfy9kZznj94RjMojyz

itrycodinganditsbad and others added 4 commits October 19, 2026 23:19
On Windows, renaming over a file fails with "Access is denied" while another
program (a virus scanner, the search indexer) holds it open without delete
sharing. The live store, status and account writes all went through that
rename and failed outright; main's Windows CI hit it in the live speed test
(a 2.5 s keystroke after "the live documents couldn't be written to disk").
The rename now retries for about 1.5 s on ERROR_ACCESS_DENIED and
ERROR_SHARING_VIOLATION before giving up.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RC1oqfy9kZznj94RjMojyz
Writing a document whole emptied its journal by deleting the file, and the
next change made it again. On Windows another program holding the journal
without delete sharing (a scanner, the indexer, a backup tool) made the
delete fail with ERROR_SHARING_VIOLATION, so the document's write failed.
One holding it without write sharing failed the next append the same way.

The journal now stays for the life of the document: emptied in place with
set_len(0) and an fsync, like a cut. Its records are in the document's file
before it is emptied, so a crash before the cut is durable still loads them
and skips them by client_seq, as before. Opening the journal for an append,
a cut or the emptying waits for a holder for up to about 1.5 s, like
write_atomic's rename (the retry is now retry_busy, shared by both).

Files that do go (a document or recents file that doesn't read, and
state.json and account.json when the account is forgotten or replaced) are
renamed to a temporary name of their own first and then deleted (retire),
so their names are free at once for the file written next. A temporary file
a crash leaves is removed by the existing clean-ups.

The lead was a deleted file staying "delete pending" on Windows while a
scanner holds it, so that making the journal again fails with
ERROR_ACCESS_DENIED. On NTFS on current Windows (checked on Windows 11,
build 26200) a delete frees the name at once (POSIX semantics), so that
part doesn't happen there; the test for it passed before this change too. The two journal tests and the
state.json test failed before it with os error 32.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RC1oqfy9kZznj94RjMojyz
…n and p95

The test sorted the keystroke times and failed on the single worst one, so
a failure didn't say which keystroke was slow or what happened around it.
It now prints every keystroke in order with when it was sent, how long it
took, and anything else the two documents said meanwhile (going offline,
for one). It asserts the median under 100 ms, the 95th percentile under
250 ms and the worst under 1 s. A delay on every keystroke, or on every
tenth one, still fails. One keystroke held up once on a busy runner doesn't.
A unit test checks the bounds against those cases.

The test also waits for the pc's document to come online before typing,
not only the laptop's. After an import the pc catches up on its own import
from the server, and a keystroke sent before that ends waits in the
journal, so it timed the rest of the catch-up instead of a keystroke.

The agents stop before the assertions. Before this, a failed assertion
deleted the agents' temporary folders while they were still running, which
printed "the live documents couldn't be written to disk" (os error 2 on
Linux and macOS, os error 5 on Windows). Those lines came from the failure,
they didn't cause it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RC1oqfy9kZznj94RjMojyz
Right after a file is opened live, CI on Windows took 0.3 to 1.4 s per
keystroke for about 4 s, and then settled. Two writes ran on threads that
also relay every keystroke, and both waited for any other program holding
the file they replace (retry_busy, up to about 1.5 s):

- status.json, written on the run loop's runtime on every state change and
  heartbeat. That runtime also serves the local API, including the docs
  WebSocket that carries each keystroke from the app and to the other app.
- a live document's whole file and the emptying of its journal, written on
  the hub's thread after a join and every 2 s while the document is dirty.
  That thread forwards every update to and from the server.

Now status.json is written on a thread of its own (StatusWriter). Only the
newest status waits there, a failed write is still tried again at the next
write, and dropping the writer writes what still waits, so the loop's last
status is on disk when it ends. The hub's writes that nothing waits for
(flush and the 2 s flush_documents) don't wait for a holder of a document's
files: the document stays dirty and is written at the next 2 s write. Its
local updates are in its journal already. A journal that can't be emptied
yet keeps records the file now has, and a load skips them by client_seq.
Writes something does wait for still wait: an app's change written whole
first, loads, recents adds, and the documents written when the agent stops.

The new Windows test, a_keystroke_isnt_held_up_by_a_scanner_holding_the_
files_written_now_and_then, holds both agents' status.json and live
document files open without delete sharing for 400 ms out of every 450 ms.
On the previous commit it fails: median 33 ms, 95th percentile 2.0 s,
keystrokes of 0.7 to 2.3 s. With this commit it passes at about 32 ms.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RC1oqfy9kZznj94RjMojyz
@itrycodinganditsbad itrycodinganditsbad changed the title Agent: retry a file replace that Windows refuses for a moment Agent: keep keystrokes moving while Windows holds the agent's files Oct 20, 2026
itrycodinganditsbad and others added 2 commits October 20, 2026 03:18
…lone

On 6b955b5, Windows CI failed both latency tests with "keystroke 5 never
arrived": no update within the 5 s recv_timeout. The log can't say whether
the update was lost or only late, because the test stopped at 5 s.

The test now waits up to a minute for a keystroke that is over 5 s late.
It records what each agent has in its live folder (documents and journals,
with sizes) when the keystroke is 5 s late and again when it arrives. Only
one that never comes panics, with that state. A late one fails the bounds
with the in-order report. CRAFT_CLOUD_TEST_LOAD=n adds n busy threads while
typing, as live_autosave does on app-kit.

On this PC, 30 runs with the scanner and 32 to 96 busy threads, plus 20
traced runs, gave 10 keystrokes of 3.6 to 11 s. Every one arrived; none was
lost. Tracing showed each gap inside one agent thread between two
consecutive steps with no I/O in between (for example, the pc's hub between
appending an update and queuing it for the server, 4.0 s). That is the
thread not being run under full CPU, not something in our code holding it.

The two latency tests now take turns (a shared lock). Since 6b955b5 they
ran at once: four agents and two servers on a CI runner's few cores, each
test timing the other.

A unit test checks the paths 6b955b5 added for losing data. A document
write refused because another program holds the document, or one whose
journal is held, leaves the document dirty or the journal as it was. A crash
then reads every acked change back, each once, and the next write catches
up. It passes on 6b955b5 too: the skipped writes don't lose updates.
MemStore can now simulate a held file (Held), and is_busy also accepts
ErrorKind::ResourceBusy, the kind MemStore returns.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RC1oqfy9kZznj94RjMojyz
… thread

binary.rs's a_second_run_exits_0_with_a_note failed on Windows CI: `status`,
run right after a second `run`, printed only "not signed in". It found no
running loop in status.json. The test, the app and a second `run` all take
"the local API is up" to mean "status.json says a loop runs". That held
before 6b955b5 only by accident. The first status write ran on the loop's
runtime, which also serves the local API, so the API couldn't answer until
the write was done. With the write on the status thread, the API answered
first.

The loop now writes its first status itself, before it starts the local
API: Idle when signed in, SignedOut when not, Paused when the account doesn't
read. If the API can't start, it writes Stopped. At the end, the loop
finishes the status thread (so Stopped is on disk) before it stops the API
and lets go of the data folder. Before this, the last status could land
after the lock was released, over the status of a `run` started next.
Everything in between still goes through the status thread.

once_the_local_api_is_up_status_json_says_the_loop_runs (Windows) holds an
old status.json for 1 s as the loop starts and reads status.json as soon as
local-api.json appears. On ba6a69e it fails (it reads the old file). With
this commit it passes.

live_documents.rs's an_app_that_misbehaves_is_refused_or_dropped_and_the_
rest_go_on failed once on macOS: the app that never reads wasn't dropped.
The agent drops an app whose message it can't send within 10 s, and the 60
changes of 200 KB (12 MB) only hold a send up once the kernel's loopback
buffers are full. macOS can let those grow to megabytes. That app now
connects with a 64 KiB receive buffer, so the agent's send waits however
large the system would let buffers grow. Nothing in this branch touches the
drop: the docs socket, its 10 s limit and the hub's queue are as on main.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RC1oqfy9kZznj94RjMojyz
@itrycodinganditsbad
itrycodinganditsbad merged commit e2a5ffe into main Oct 20, 2026
4 checks passed
@itrycodinganditsbad
itrycodinganditsbad deleted the windows-replace-retry branch October 21, 2026 00:25
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.

1 participant