diff --git a/services/web/frontend/js/features/ide-react/editor/offline-doc-backup.ts b/services/web/frontend/js/features/ide-react/editor/offline-doc-backup.ts index e687870375..d39ff42764 100644 --- a/services/web/frontend/js/features/ide-react/editor/offline-doc-backup.ts +++ b/services/web/frontend/js/features/ide-react/editor/offline-doc-backup.ts @@ -8,6 +8,7 @@ import { IdeEventEmitter } from '@/features/ide-react/create-ide-event-emitter' const EVENT_NAMESPACE = 'offlineBackup' const OFFLINE_BACKUP_INTERVAL_MS = 2000 const KEY_PREFIX = 'doc.offline-backup.' +const STALL_AFTER_SECONDS = 10 type Baseline = { version: number @@ -101,6 +102,20 @@ export class OfflineDocBackup { return this.doc.connection.state !== 'ok' } + private isSavingStalled() { + const oldestOpCreatedAt = + this.doc.getInflightOpCreatedAt() ?? this.doc.getPendingOpCreatedAt() + if (!oldestOpCreatedAt) { + return false + } + const unsavedSeconds = (performance.now() - oldestOpCreatedAt) / 1000 + return unsavedSeconds > STALL_AFTER_SECONDS + } + + private shouldBackup() { + return this.isOffline() || this.isSavingStalled() + } + private refreshBaseline() { // baseline is the clean server state, buffered ops would // bake local edits into V_start and corrupt recovery @@ -144,7 +159,7 @@ export class OfflineDocBackup { // reflects remote ops applied before we go offline. refreshBaseline no-ops // once there are buffered ops, freezing the baseline at the point we start // diverging offline. - if (!this.isOffline()) { + if (!this.shouldBackup()) { this.refreshBaseline() return } @@ -156,7 +171,7 @@ export class OfflineDocBackup { } this.throttleTimer = window.setTimeout(() => { this.throttleTimer = null - if (this.isOffline() && this.doc.hasBufferedOps()) { + if (this.shouldBackup() && this.doc.hasBufferedOps()) { this.write() } }, OFFLINE_BACKUP_INTERVAL_MS) diff --git a/services/web/test/frontend/features/ide-react/unit/editor/offline-doc-backup.test.ts b/services/web/test/frontend/features/ide-react/unit/editor/offline-doc-backup.test.ts index 2764fcf29b..1385eb93f9 100644 --- a/services/web/test/frontend/features/ide-react/unit/editor/offline-doc-backup.test.ts +++ b/services/web/test/frontend/features/ide-react/unit/editor/offline-doc-backup.test.ts @@ -15,6 +15,7 @@ const PROJECT_ID = 'project-456' const USER_ID = 'user-789' const KEY = `doc.offline-backup.${USER_ID}.${PROJECT_ID}.${DOC_ID}` const FLUSH_DELAY = 2000 +const STALL_AFTER_MS = 10000 const SYNCED_EVENT = 'ide:offlineChangesSynced' class FakeShareJsDoc extends EventEmitter { @@ -27,6 +28,8 @@ class FakeShareJsDoc extends EventEmitter { snapshot = 'server text' inflightOp: ShareJsOperation | null = null pendingOp: ShareJsOperation | null = null + inflightOpCreatedAt: number | null = null + pendingOpCreatedAt: number | null = null track_changes = false otType: OTType = 'sharejs-text-ot' _doc = { inflightSubmittedIds: new Set() } @@ -60,6 +63,14 @@ class FakeShareJsDoc extends EventEmitter { return this.pendingOp } + getInflightOpCreatedAt() { + return this.inflightOpCreatedAt + } + + getPendingOpCreatedAt() { + return this.pendingOpCreatedAt + } + getInflightSubmittedIds() { return this._doc.inflightSubmittedIds } @@ -151,6 +162,41 @@ describe('OfflineDocBackup', function () { expect(readRecord()).to.be.null }) + it('backs up while still connected once saving stalls past the threshold', function () { + create() + doc.pendingOp = [{ i: 'x', p: 0 }] + doc.pendingOpCreatedAt = performance.now() - (STALL_AFTER_MS + 1000) + doc.trigger('change') + clock.tick(FLUSH_DELAY) + + expect(doc.connection.state).to.equal('ok') + expect(readRecord()?.pendingOp).to.deep.equal([{ i: 'x', p: 0 }]) + }) + + it('does not back up while connected and within the stall threshold', function () { + create() + doc.pendingOp = [{ i: 'x', p: 0 }] + doc.pendingOpCreatedAt = performance.now() - (STALL_AFTER_MS - 5000) + doc.trigger('change') + clock.tick(FLUSH_DELAY) + + expect(readRecord()).to.be.null + }) + + it('uses the inflight op age (the oldest) to detect a stall', function () { + create() + doc.inflightOp = [{ i: 'sent', p: 0 }] + doc.inflightOpCreatedAt = performance.now() - (STALL_AFTER_MS + 1000) + doc.pendingOp = [{ i: 'queued', p: 5 }] + doc.pendingOpCreatedAt = performance.now() + doc.trigger('change') + clock.tick(FLUSH_DELAY) + + const record = readRecord() + expect(record?.inflightOp).to.deep.equal([{ i: 'sent', p: 0 }]) + expect(record?.pendingOp).to.deep.equal([{ i: 'queued', p: 5 }]) + }) + it('writes the baseline plus live ops when offline with buffered ops', function () { create() goOffline()