From e731edd34f59368af0cbfd6390099a454f1dda21 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 | 78 ++++++++++++++++++++ playwright/support/sections/EditorSection.ts | 4 + src/components/Editor.vue | 12 ++- src/services/SyncService.ts | 11 ++- 4 files changed, 96 insertions(+), 9 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..6144fcd6ba4 --- /dev/null +++ b/playwright/e2e/session-rejected.spec.ts @@ -0,0 +1,78 @@ +/** + * 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/playwright/support/sections/EditorSection.ts b/playwright/support/sections/EditorSection.ts index 9ec489a7431..365a7b5ca26 100644 --- a/playwright/support/sections/EditorSection.ts +++ b/playwright/support/sections/EditorSection.ts @@ -40,6 +40,10 @@ export class EditorSection { await this.content.pressSequentially(keys) } + public async press(key: string): Promise { + await this.content.press(key) + } + public async typeHeading(name: string): Promise { await this.type('## ') await this.type(name) diff --git a/src/components/Editor.vue b/src/components/Editor.vue index 7e43077f3b0..52542786bba 100644 --- a/src/components/Editor.vue +++ b/src/components/Editor.vue @@ -389,7 +389,7 @@ export default defineComponent({ : '/' }, displayed() { - return (this.connection && this.active) || this.syncError + return (this.active && (this.connection || this.idle)) || this.syncError }, showLoadingSkeleton() { return (!this.contentLoaded || !this.displayed) && !this.syncError @@ -688,15 +688,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 7be75cc2381..12a1822724f 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: {}, @@ -331,6 +333,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()) {