Skip to content

text: a crash checkpoint no longer splits a run of typing - #601

Merged
foxnne merged 2 commits into
mainfrom
text/checkpoint-keeps-typing-run
Oct 19, 2026
Merged

foxnne merged 2 commits into
mainfrom
text/checkpoint-keeps-typing-run

Conversation

@foxnne

@foxnne foxnne commented Oct 18, 2026

Copy link
Copy Markdown
Collaborator

Fixes the flaky step check in the integration tour test ("Integration tests (Linux)" on #583, 2026-10-18: .added where .joined was expected).

What changes

Typing in a document with unsaved changes stays one undo step, however long you type. Before, the crash checkpoint split it every few seconds.

The cause wasn't a join window. The text plugin joins keystrokes by run of operation (VSCode's rule), with no timer. What cut the run was the crash checkpoint (src/editor/Checkpoint.zig). It runs on the wall clock, at least 3 s after the last checkpoint and once there has been input, and captures every dirty document through KeptDocuments.capture. For the unsaved bytes, that calls documentBytes, which goes documentSnapshot → Document.savedBytes, and savedBytes called history.closeGroup() as though the save had happened. On a slow runner, a checkpoint landed between the test's two keystrokes. Reproduced locally with a 4 s sleep between them (gives the CI failure exactly); a 1.5 s sleep doesn't, because it stays inside the checkpoint's spacing.

The fix: savedBytes still remembers which edit the bytes stand for, but no longer closes the group. written (the documentWritten hook, which only a real save reports: DocumentIo and the web download) makes the undo stop, while the written state is still the top step. The synchronous save path is unchanged.

One behavior moves: a keystroke typed while a save's write is in flight (milliseconds, off-thread) now joins the step the save captured. The document stays dirty, which is correct because that step is newer than the file, but undo goes past the saved state instead of stopping on it. Before, the snapshot closed the group up front.

The test doesn't touch the clock. It takes a checkpoint's capture itself (KeptDocuments.capture) between "a" and "b" and still expects .joined, so it covers the bug however long a frame takes. A new check covers the save side: type, save through the host's path (documentBytes + documentWritten), type, undo. That should give back the saved text, clean. Each check fails when its half of the fix is removed (verified by reverting savedBytes, and separately by dropping the closeGroup in written).

SDK impact

  • None
  • Core-only or additive: reaches plugins at the next SDK release
  • Fingerprint moved: recorded in sdk/src/version.zig, sdk_version untouched, PR labelled sdk
  • SDK release: bumps sdk_version, lists the sdk PRs since the last sdk-v* tag

Verified

  • macOS: zig build test-integration (the tour test with the new checks; it fails with either half of the fix reverted), plus zig build, test, check-web, test-sdk-version, scripts/check-fmt.sh
  • Windows:
  • Linux: CI
  • Web: check-web builds; not run

Follow-ups

The root of this is the host using the save's snapshot for things that aren't saves: the crash checkpoint, a restart or plugin reload (KeptDocuments), and Tracks' share export. This PR fixes the undo split inside the text plugin. Two problems stay, and want a host-side seam (bytes as a save would write them, without a save's bookkeeping):

  • With format-on-save on (off by default), a checkpoint runs the formatter on a dirty document while it is being edited (documentSnapshot formats before taking the bytes).
  • A checkpoint taken while a save is in flight overwrites serialized_op_id. If an edit came between the two, the save lands and marks the document clean at an edit newer than what was written.

🤖 Generated with Claude Code

The crash checkpoint (`Checkpoint`) captures every dirty document every few
seconds of typing, and for its unsaved bytes asks the owner for the bytes a
save would write (`documentBytes`). The text plugin's `savedBytes` closed the
undo group there, as if it were the save, so a run of typing was cut into a
new undo step each time a checkpoint landed in it. It is also what made the
tour test's step check flaky: on a slow runner a checkpoint fell between the
two keystrokes it types, and the second came as `.added`, not `.joined`.

The save's undo stop now comes when its write lands (`written`), which only a
real save reports, and only while the written state is still the top step.
The test takes a checkpoint between its keystrokes itself, so it holds
whatever a frame takes, and checks that a save still stops the run.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@foxnne
foxnne marked this pull request as ready for review October 19, 2026 01:33
@foxnne
foxnne merged commit 41eafea into main Oct 19, 2026
11 checks passed
@foxnne
foxnne deleted the text/checkpoint-keeps-typing-run branch October 19, 2026 03:29
foxnne added a commit that referenced this pull request Oct 19, 2026
#606)

Stacked on #601 and based on its branch, so the diff is
this change alone; GitHub retargets it to `main` when #601 merges. It
takes up the follow-ups that PR lists.

## What changes

**For plugin authors:** a new optional
`Plugin.VTable.documentBytes(state, doc, path, allocator) → []u8`. It
returns the bytes a save to `path` would write, for a copy that is
**not** a save, with none of a save's bookkeeping or side effects: no
format on save, no edit remembered for `documentWritten`, no undo stop.
fizzy takes these bytes:

- for the crash checkpoint (`KeptDocuments.capture` from `Checkpoint`,
every few seconds of typing)
- for a restart or a plugin reload carrying unsaved work
- for a tape's file (`Tracks.fileAt`)
- for a `fizzy.document.changed` listener that asks for the unsaved text

An owner without the hook keeps working as before:
`Plugin.documentBytes` falls back to `documentSnapshot` +
`encodeSnapshot`, bookkeeping included.

Saves keep the snapshot: `DocumentIo.save`, and the web download
(`Editor.downloadDocument`), which now calls the new `Plugin.saveBytes`
(both halves of a save at once, followed by `documentWritten`).

**Why:** `documentSnapshot` is "the storage-agnostic half of saving". It
runs at a save's start, so it formats on save and records which edit the
bytes stand for. Every non-save copy did the same. In the text plugin
that meant:

1. With `format_on_save` on (off by default), each checkpoint ran the
formatter on a dirty document while the person was typing in it.
2. A checkpoint taken while a save's write was in flight overwrote
`serialized_op_id`. If an edit came in between, the write landed and
`documentWritten` marked the document clean at an edit newer than the
file on disk.

**Implemented in:**

- **text**: the hook returns the text as it stands.
- **archive**: it had the same in-flight bug through
`serialized_generation`, so it gets the hook too (`doc.pack`, with no
generation recorded).

`Document.savedBytes`'s comment now says only a save takes it.
`docs/PLUGINS.md` names the hook, and its storage-agnostic section now
names the snapshot pair, which is what a save uses.

**Tests** (`tests/integration.zig`, the tour test's "A document's steps"
block, after #601's save check):

- **Format on save on, with a stand-in formatter:** a
`KeptDocuments.capture` keeps the text as typed and leaves the document
unformatted. A real save (`saveBytes`) still formats, which shows the
formatter was live.
- **A checkpoint while a save is in flight:** take a save's snapshot at
"a", type "b", take a checkpoint, then land the write
(`documentWritten`). The document is still dirty.

Each check fails without the text plugin's hook: the first with
`formatted` where `as typed` was expected, the second with the document
clean.

## SDK impact

- [ ] None
- [ ] Core-only or additive: reaches plugins at the next SDK release
- [x] Fingerprint moved: recorded in `sdk/src/version.zig`,
`sdk_version` untouched, PR labelled `sdk`
- [ ] SDK release: bumps `sdk_version`, lists the `sdk` PRs since the
last `sdk-v*` tag

A new optional vtable field, null by default. An existing plugin builds
unchanged and gets the fallback, which behaves as before.

**pixi** (`fizzyedit/pixi`) implements `documentSnapshot` and has both
problems in its own form:

- `File.saveSnapshot` sets `editor.saving_bookmark = history.bookmark`,
so a checkpoint during a save moves the bookmark that `written` cleans
to.
- It also calls `finishReveal()`, so a checkpoint ends a reveal in
progress.

pixi would need a `documentBytes` that builds the same `Snapshot`
content without those two lines, encodes it in place (`File.encode`),
and destroys it. The `.pixi` zip encode then runs on the UI thread at
each checkpoint, as it already does through the fallback today.

## Verified

- [x] macOS: `zig build test-integration` (80/80 steps; the two new
checks each fail with the text hook removed), `zig build`, `zig build
test` (113/113), `check-web` (42/42), `test-sdk-version` (35/35).
`scripts/check-fmt.sh` flags only
`backend/src/platform/win32_titlebar.zig`, which this branch's base
(#601's) predates `main`'s fix for (#597); untouched here.
- [ ] Windows:
- [ ] Linux: CI
- [ ] Web: `check-web` builds; the download path was not run

## Follow-ups

- pixi's `documentBytes` is written and builds against this branch's
SDK, with pixi's `zig build test` passing. It's a local change for the
pixi repo, pushed at the repin that brings this SDK, since SDK 0.2.30
has no such field. It splits `saveSnapshot` into the save's bookkeeping
around a `takeSnapshot` that changes nothing. A drawing still being
revealed is completed in the copy, as `captureState` does; a flat image
is the one case that still finishes the reveal, because its composite is
read back from the GPU.
- The checkpoint still encodes on the UI thread for every owner. If a
large document shows up in a frame, the seam is to snapshot through the
hook and encode on `DocumentIo`'s worker.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
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