- A save that failed on a short platform failure (a lost or refused
database connection, lack of resources, a server shutting down, a
serialization failure or deadlock, a socket or HTTP timeout, a host
that did not resolve, an interruption) answered 500, which stops
autosave. It now answers 503, which the frontend retries. Other I/O
and database errors keep answering 500, timeouts 504.
- A save answered not-found no longer replaces the workspace with the
access page. It is a terminal save error: the workspace stays open
with the failed save notice, report and reload link. When edit rights
are lost, the notice says how many changes could not be saved.
- A library sync marked the library synced three seconds later,
whatever happened to its save. A rejected sync was undone but stayed
marked: copies stayed out of date and the "updates available" notice
never came back. The mark now waits for the save, a rollback of the
sync brings the notice back, and a failed mark (e.g. the user just
became a viewer) is logged instead of leaving the workspace.
- While saves fail the workspace is read-only. The view-mode banner
says editing is disabled because changes can't be saved, with the
Inspect banner's size and bold lead and no Done button. Users can
still switch pages, pan, zoom and inspect. The lock lifts when a save
lands and brings back the mode the user had or picked meanwhile; the
WebGL context lock keeps the workspace read-only if it lasts longer.
The lock lives in app.main.data.persistence.lock.
- A viewer is never locked. A role change that leaves the user a viewer
keeps the edits that lost permission, queued and on screen, and shows
the count notice again. The role change that brings edit rights back
releases them and rolls them back on screen, as a rejected save would.
Any other terminal failure is released by the next role change or file
opened.
The debug faults `io` (socket timeout, 503) and `eof` (500) cover both
sides of the I/O rule.
A save whose changes failed validation was rejected as a whole: autosave
stopped, the edits made after it piled up unsent, and a reload lost
them all, the valid ones included.
The frontend now sends each commit of a save as its own group with its
commit id (:changes-with-metadata). When applying and validating all of
them at once fails, the backend searches by bisection for the first
commit that fails, keeps the commits before it and answers with
:rejected (that commit and every later one). Only the kept changes are
stored, snapshotted and broadcast, so other users never see the rejected
ones. Flat :changes and groups without ids stay all-or-nothing; database
and network errors still fail the whole request. A rejection without an
error type is reported as an internal error.
The frontend rolls the file back to what the server holds: it undoes
every pending commit of that file, applies again the remote changes that
arrived meanwhile, and redoes the kept commits. Undoing only the
rejected commits would write stale values over a peer's later change to
the same attribute. The rejected and later commits leave the queue, the
buffer and the undo history, and a warning that hides after ten seconds
tells the user. The pure bookkeeping lives in
app.main.data.persistence.commits.
The pending notice of a commit the rollback dropped no longer leaves the
save status at pending, which made "Pin this version" hang and fail.
Debug tools drive these cases by hand: debug.commitInvalidChange() makes a
save fail validation, holdSaves() delays the answers, failNextSave() and
failNextRollback() fail them on purpose (backend flag debug-save-faults),
and persistenceState() shows the queue.