# Fix the realtime saver hanging when the save request fails at network level
## Context
When the save request issued by the realtime auto-save fails at the network level (request blocked
by the browser, server unreachable, connection reset — i.e. HTTP status `0`), the realtime editing
session gets permanently stuck:
* the realtime toolbar keeps showing "Saving" instead of going back to "Unsaved",
* the failed save is never retried,
* the *Done* button stays disabled.
Tracing confirms `await this._submit(options)` in `GenericSaver#_save()`
([saver.js:189](xwiki-platform-core/xwiki-platform-realtime/xwiki-platform-realtime-webjar/src/main/webjar/saver.js#L189))
never settles.
### Root cause
The save is performed by simulating a click on the `action_saveandcontinue` button, so the actual
request is `XWiki.actionButtons.AjaxSaveAndContinue`'s `Ajax.Request`. `XWikiSaver#_submit()` waits
for the `xwiki:document:saved` / `xwiki:document:saveFailed` events
([saver.js:619-635](xwiki-platform-core/xwiki-platform-realtime/xwiki-platform-realtime-webjar/src/main/webjar/saver.js#L619-L635)).
For a network-level failure Prototype reports `status === 0`, so `on0`
([actionButtons.js:358](xwiki-platform-core/xwiki-platform-web/xwiki-platform-web-war/src/main/webapp/resources/js/xwiki/actionbuttons/actionButtons.js#L358))
delegates to `onFailure`, which starts with:
```js
onFailure : function(state, response) {
this.enableEditors();
this.savingBox.replace(this.failedBox);
this.progressBox.replace(this.failedBox);
if (response.getHeader('Content-Type').match(/^\s*text\/plain/)) { // <-- line 749
...
}
...
state.saveButton.fire("xwiki:document:saveFailed", {'response' : response}); // <-- line 758, never reached
},
```
Prototype's `getHeader()` returns **`null`** when no response header is available
(`prototype.js`: `try { return this.transport.getResponseHeader(name) || null } catch (e) { return null }`),
which is exactly the case for a blocked/failed request. `null.match(...)` throws a `TypeError`,
`Ajax.Request#respondToReadyState` catches it and routes it to `dispatchException` (silently, since
no `onException` option is set), and **`xwiki:document:saveFailed` is never fired**.
Consequences:
* `_getSubmitResult()`'s promise never settles → `_submit()` → `_save()` hang forever →
`this._state.saving` is never reset to `0` → the toolbar status stays `1` ("Saving") and
`Toolbar#_disableSaveTriggersIf()` keeps the *Done* (and *Summarize*) button disabled
([toolbar.js:271-286](xwiki-platform-core/xwiki-platform-realtime/xwiki-platform-realtime-webjar/src/main/webjar/toolbar.js#L271-L286)).
* `_maybeSave()` short-circuits on `_isSomeoneSaving()`, so no further auto-save ever runs.
* The same hang affects in-place editing outside realtime: `push()` in
[InplaceEditing.xml](xwiki-platform-core/xwiki-platform-edit/xwiki-platform-edit-ui/src/main/resources/XWiki/InplaceEditing.xml)
(~line 651) waits for the same events and leaves `.inplace-editing-buttons` disabled.
Fixing `onFailure` restores the failure signal; a second, independent gap remains: even once
`_submit()` rejects, nothing reschedules the auto-save (`_scheduleSave()` is only called from
`contentModifiedLocally()`), so the save is not retried until the user types again.
Proposed fix:
## Changes
### 1. `actionButtons.js` — always fire `xwiki:document:saveFailed`
File:
[xwiki-platform-web/.../actionbuttons/actionButtons.js](xwiki-platform-core/xwiki-platform-web/xwiki-platform-web-war/src/main/webapp/resources/js/xwiki/actionbuttons/actionButtons.js#L745-L759)
Rewrite `onFailure` so that:
* the `Content-Type` lookup is null-safe — `response.getHeader('Content-Type')?.match(/^\s*text\/plain/)`
(optional chaining is already used elsewhere in this file, e.g. `response.responseJSON?.links`);
* the notification update cannot prevent the event from being fired — move the status-message part
into a `try { ... } finally { state.saveButton.fire("xwiki:document:saveFailed", {response}); }`,
so any future failure in the notification code degrades to a wrong message instead of a stuck
editor.
With the header being `null`, `response.statusText` is also `''` for network errors, so the existing
`'Server not responding'` fallback is displayed — which is the message this code was already written
to produce.
### 2. `saver.js` — retry the auto-save after a failed save
File:
[realtime-webjar/.../saver.js](xwiki-platform-core/xwiki-platform-realtime/xwiki-platform-realtime-webjar/src/main/webjar/saver.js#L176-L200)
At the end of `GenericSaver#_save()`, after `this._state.saving = 0` and the final `_updateState()`,
reschedule a save while the content is still dirty:
```js
this._state.saving = 0;
this._updateState(true, true);
if (this._state.dirty) {
// The content is still dirty (the save failed, or another client was elected to save and didn't
// manage to). Schedule a new save attempt.
this._scheduleSave();
}
```
This is safe with respect to the existing scheduling logic: `_updateState()` deletes
`_dirtyTimestamp` while someone is saving, so at this point `_dirtyTimestamp` is `undefined` and
`_scheduleSave()` arms a timer for `SAVE_INTERVAL` (~60 s) instead of saving immediately — no tight
retry loop against a failing server. `_scheduleSave()` also clears the previous timer, so it stays
idempotent with `contentModifiedLocally()`.
Covering "still dirty" rather than only the `catch` block also handles the case where another client
was elected by `_getSavingClientId()` and then failed to save.
### 3. `saver.js` — propagate the save failure to the callers
`GenericSaver#_save()` currently swallows every failure into a `console.warn`, so `save()` always
resolves. `Toolbar#_saveChangeSummary()`
([toolbar.js:230-237](xwiki-platform-core/xwiki-platform-realtime/xwiki-platform-realtime-webjar/src/main/webjar/toolbar.js#L230-L237))
already wraps `await this._config.save(continueEditing)` in a `try`/`catch` that restores
`_lastReviewedVersion` — dead code today, so the "reviewed version" is lost even when nothing was
saved. Restructure `_save()` to keep the log **and** rethrow, with the state reset in a `finally`:
```js
try {
const savingClientId = await this._getSavingClientId();
if (savingClientId === this._getClientId()) {
const savedUpdateCount = this._getUpdateCounts();
debug("Saving ", savedUpdateCount);
await this._submit(options);
this._state.savedUpdateCount = savedUpdateCount;
}
} catch (error) {
warn("Failed to save.", error);
throw error;
} finally {
this._state.saving = 0;
// Propagate the state immediately after a save attempt because the user may leave the edit mode
// and this will close the WebSocket connection.
this._updateState(true, true);
if (this._state.dirty) {
this._scheduleSave();
}
}
```
Both fire-and-forget call sites must then handle the rejection, otherwise the change only converts a
silent hang into an unhandled promise rejection:
* [saver.js:428](xwiki-platform-core/xwiki-platform-realtime/xwiki-platform-realtime-webjar/src/main/webjar/saver.js#L428)
(`beforeSaveHandler`) — `this._save({button: event.target}).catch(() => {});` with a comment that
the failure is already reported by `_save()` and by the save notification.
* [toolbar.js:71-91](xwiki-platform-core/xwiki-platform-realtime/xwiki-platform-realtime-webjar/src/main/webjar/toolbar.js#L71-L91)
(Done button) — make the click listener `async` and wrap `await this._config.save()` in a
`try`/`catch`. Note `_config.save` is `(...args) => this._saver?.save(...args)` in both
[wysiwygEditor.js:312](xwiki-platform-core/xwiki-platform-realtime/xwiki-platform-realtime-wysiwyg/xwiki-platform-realtime-wysiwyg-webjar/src/main/webjar/wysiwygEditor.js#L312)
and
[wikiEditor.js:57](xwiki-platform-core/xwiki-platform-realtime/xwiki-platform-realtime-wiki/xwiki-platform-realtime-wiki-webjar/src/main/webjar/wikiEditor.js#L57),
so it can return `undefined` — `await` handles that, a bare `.catch()` would not.
### 4. `saver.js` — timeout while waiting for the save result
Guard against the save-result events never being fired at all (the failure mode of this bug), so a
missing event can never freeze the auto-save for the rest of the session. Add a constant next to
`SAVE_INTERVAL` / `SAVE_DELAY`:
```js
// How long to wait for the result of a save request before giving up. This is a safety net for the
// case where neither the save success nor the save failure event is fired.
const SUBMIT_TIMEOUT = 120000;
```
Add an optional `timeout` parameter to `_getSubmitResult(form, removeListeners, timeout)` that arms
a `setTimeout` rejecting with an explicit error, and register its `clearTimeout` in
`removeListeners` — the existing `_once()` wrapper already runs every entry of that array as soon as
one of the events fires, so the timer is disarmed automatically on both success and failure.
Pass `SUBMIT_TIMEOUT` **only** from `_submit()`
([saver.js:578](xwiki-platform-core/xwiki-platform-realtime/xwiki-platform-realtime-webjar/src/main/webjar/saver.js#L578)),
not from the nested call in `_waitForMergeConflictResolution()`
([saver.js:644](xwiki-platform-core/xwiki-platform-realtime/xwiki-platform-realtime-webjar/src/main/webjar/saver.js#L644)):
once a 409 has been received the saver is waiting for the *user* to resolve the merge conflict
modal, which legitimately takes arbitrarily long.
2 minutes is deliberately well above any realistic save round-trip: on timeout the content stays
dirty and will be saved again, so a too-short value would risk a duplicate save (and an extra
version) on a slow but successful request.
This message was sent by Atlassian Jira (v9.3.0#930000-sha1:287aeb6)
If image attachments aren't displayed, see this article.