From 90ddfaf1c9f47e5fc9f7b04605ba5033e1e4c961 Mon Sep 17 00:00:00 2001 From: Jonas Date: Wed, 9 Sep 2026 16:50:47 +0200 Subject: [PATCH] fix(SyncService): Behave like an idle disconnect on 403 responses When the editing session went invalid (i.e. because a browser or tab was sleeping and didn't send a heartbeat for more than five minutes), the server replies with 403 responses to sync/push requests. The client-side polling backend correctly disconnects but the push side (sync service) continues to send push requests with awareness state updates and optionally pending steps. This commit changes the sync service to stop pushing to the server on 403s as well by invalidating the session client-side. The UI now handles the `PUSH_FORBIDDEN` error like an idle disconnect: it sets the `idle` state which causes the status badge with reconnect button to be displayed. Fixes: #8950 Signed-off-by: Jonas Assisted-by: ClaudeCode:claude-fable-5-1 --- playwright/e2e/session-rejected.spec.ts | 76 +++++++++++++++++++++++++ src/components/CollaborativeEditor.vue | 10 ++-- src/services/SyncService.ts | 11 +++- 3 files changed, 90 insertions(+), 7 deletions(-) create mode 100644 playwright/e2e/session-rejected.spec.ts diff --git a/playwright/e2e/session-rejected.spec.ts b/playwright/e2e/session-rejected.spec.ts new file mode 100644 index 00000000000..d2ba73b972a --- /dev/null +++ b/playwright/e2e/session-rejected.spec.ts @@ -0,0 +1,76 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type { Route } from '@playwright/test' + +import { expect, mergeTests } from '@playwright/test' +import { test as editorTest } from '../support/fixtures/editor.ts' +import { test as uploadFileTest } from '../support/fixtures/upload-file.ts' + +const test = mergeTests(editorTest, uploadFileTest) + +// Only sync and push are rejected. `create` stays reachable so that +// reconnecting can open a fresh session. +const SESSION_REQUESTS = /\/apps\/text\/session\/\d+\/(sync|push)$/ + +// The awareness heartbeat pushes every 15 seconds. +// Waiting longer than that proves that the client stopped pushing. +const HEARTBEAT_TIMEOUT = 20_000 + +test.beforeEach(async ({ open }) => { + await open() +}) + +test('stops syncing and offers to reconnect once the session is rejected', async ({ + editor, + page, +}) => { + test.slow() + await expect(editor.el).toBeVisible() + await expect(editor.sessionList).toBeVisible() + + const status = page.locator('.document-status') + const content = editor.el.locator('.ProseMirror') + await expect(content).toHaveAttribute('contenteditable', 'true') + + // Answer like SessionMiddleware does for a session it no longer knows. + let pushCount = 0 + const rejectSession = async (route: Route) => { + if (route.request().url().endsWith('/push')) { + pushCount++ + } + await route.fulfill({ + status: 403, + contentType: 'application/json', + body: '[]', + }) + } + + await page.route(SESSION_REQUESTS, rejectSession) + + // Typing triggers a push right away instead of waiting for the heartbeat. + await editor.type('Hello') + + await expect(status).toContainText("You've been disconnected from the server.") + await expect(status.getByRole('button', { name: 'Reconnect' })).toBeVisible() + await expect(content).toHaveAttribute('contenteditable', 'false') + + const pushesUntilRejected = pushCount + await new Promise((resolve) => setTimeout(resolve, HEARTBEAT_TIMEOUT)) + expect(pushCount).toBe(pushesUntilRejected) + + await page.unroute(SESSION_REQUESTS, rejectSession) + const createRequest = page.waitForRequest(/\/apps\/text\/session\/\d+\/create$/) + await status.getByRole('button', { name: 'Reconnect' }).click() + await createRequest + + await expect(status).not.toContainText("You've been disconnected from the server.") + await expect(editor.sessionList).toBeVisible() + await expect(content).toHaveAttribute('contenteditable', 'true') + await expect(editor.content).toContainText('Hello') + + await editor.press('Enter') + await editor.typeHeading('Back again') +}) diff --git a/src/components/CollaborativeEditor.vue b/src/components/CollaborativeEditor.vue index 3ceefab14a4..5fe10dca3fe 100644 --- a/src/components/CollaborativeEditor.vue +++ b/src/components/CollaborativeEditor.vue @@ -424,7 +424,7 @@ export default defineComponent({ }, displayed() { - return (this.connection && this.active) || this.syncError + return (this.active && (this.connection || this.idle)) || this.syncError }, showLoadingSkeleton() { @@ -728,13 +728,13 @@ export default defineComponent({ } if (type === ERROR_TYPE.PUSH_FORBIDDEN) { + // Server rejected the session. Behave like an idle disconnect: + // read-only editor with a permanent status card offering to reconnect. + this.idle = true + this.hasConnectionIssues = false this.readOnly = true this.editMode = false this.setEditable(this.editMode) - showWarning(t( - 'text', - 'Your editing permissions have been revoked. The document is now read-only.', - )) this.$emit('push:forbidden') return } diff --git a/src/services/SyncService.ts b/src/services/SyncService.ts index 4f91c318c39..b5d6c441073 100644 --- a/src/services/SyncService.ts +++ b/src/services/SyncService.ts @@ -280,8 +280,10 @@ class SyncService { data: response, }) } else if (response?.status === 403) { - // either the session is invalid or the document is read only. - logger.error('failed to write to document - not allowed') + // The server no longer accepts this session (expired, document reset or access revoked). + // Stop syncing instead of retrying with a dead session. + logger.error('Failed to push steps - session is no longer valid') + this.invalidateSession() this.bus.emit('error', { type: ERROR_TYPE.PUSH_FORBIDDEN, data: {}, @@ -333,6 +335,11 @@ class SyncService { return this.sendStepsNow().catch((err) => logger.error(err)) } + invalidateSession() { + this.backend?.disconnect() + this.connection.value = undefined + } + async close() { this.backend?.disconnect() if (this.hasActiveConnection()) {