There is 1 comment.
 
 
XWiki Platform / cid:jira-generated-image-avatar-8977dd1a-fe78-4a42-b34b-3c1240a32085 XWIKI-24690 Open

Realtime editing auto-save hangs when the save request fails at network level

 
View issue   ·   Add comment
 

1 comment

 
cid:jira-generated-image-avatar-07dd5641-66da-4cd7-b960-bc19db883fb4 Marius Dumitru Florea on 12/Aug/26 17:08
 

Claude's analysis:

# 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.