mirror of
https://github.com/yu-i-i/overleaf-cep.git
synced 2026-11-12 21:23:45 +01:00
[web] analytics for flaky connections (#35916)
* emit connection-restored events * emit post-offline sync success/failure events GitOrigin-RevId: 3fce5f328d726aeb5edf39f14239ef6e5bda2f99
This commit is contained in:
@@ -3,6 +3,7 @@ import { useLayoutEventTracking } from '@/features/ide-react/hooks/use-layout-ev
|
||||
import useSocketListeners from '@/features/ide-react/hooks/use-socket-listeners'
|
||||
import { useEditingSessionHeartbeat } from '@/features/ide-react/hooks/use-editing-session-heartbeat'
|
||||
import { useRegisterUserActivity } from '@/features/ide-react/hooks/use-register-user-activity'
|
||||
import useConnectionOutageTracker from '@/features/ide-react/hooks/use-connection-outage-tracker'
|
||||
import { useHasLintingError } from '@/features/ide-react/hooks/use-has-linting-error'
|
||||
import { Modals } from '@/features/ide-react/components/modals/modals'
|
||||
import { GlobalAlertsProvider } from '@/features/ide-react/context/global-alerts-context'
|
||||
@@ -21,6 +22,7 @@ export default function IdePage() {
|
||||
useSocketListeners() // listen for project-related websocket messages
|
||||
useEditingSessionHeartbeat() // send a batched event when user is active
|
||||
useRegisterUserActivity() // record activity and ensure connection when user is active
|
||||
useConnectionOutageTracker() // track offline episodes and emit connection-restored
|
||||
useHasLintingError() // pass editor:lint hasLintingError to the compiler
|
||||
useStatusFavicon() // update the favicon based on the compile status
|
||||
useThemedPage() // set the page theme based on user settings
|
||||
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
import { useModalsContext } from '@/features/ide-react/context/modals-context'
|
||||
import { IdeEvents } from '@/features/ide-react/create-ide-event-emitter'
|
||||
import { OfflineDocBackup } from '@/features/ide-react/editor/offline-doc-backup'
|
||||
import { ConnectionOutageTracker } from '@/features/ide-react/editor/connection-outage-tracker'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import customLocalStorage from '@/infrastructure/local-storage'
|
||||
import useEventListener from '@/shared/hooks/use-event-listener'
|
||||
@@ -591,6 +592,7 @@ export const EditorManagerProvider: FC<React.PropsWithChildren> = ({
|
||||
projectId,
|
||||
document.doc_id
|
||||
)
|
||||
ConnectionOutageTracker.recordTeardown(projectId)
|
||||
if (backupRecord) {
|
||||
if (!isConnected) {
|
||||
// Still offline, so nothing has been rejected yet: this is the
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
import customSessionStorage from '@/infrastructure/session-storage'
|
||||
import getMeta from '@/utils/meta'
|
||||
|
||||
const KEY_PREFIX = 'conn.outage.'
|
||||
|
||||
export type OutageResolution = 'reconnected' | 'saved' | 'out-of-sync'
|
||||
|
||||
export type ConnectionOutageRecord = {
|
||||
projectId: string
|
||||
detectedAt: number
|
||||
oldestUnsavedOpAt: number | null
|
||||
pendingChars: number
|
||||
inflightChars: number
|
||||
updatedAt: number
|
||||
teardownAt?: number
|
||||
}
|
||||
|
||||
export class ConnectionOutageTracker {
|
||||
static buildKey(projectId: string): string {
|
||||
const userId = getMeta('ol-user_id') ?? 'anonymous'
|
||||
return `${KEY_PREFIX}${userId}.${projectId}`
|
||||
}
|
||||
|
||||
static read(projectId: string): ConnectionOutageRecord | null {
|
||||
return customSessionStorage.getItem(this.buildKey(projectId))
|
||||
}
|
||||
|
||||
static remove(projectId: string): void {
|
||||
customSessionStorage.removeItem(this.buildKey(projectId))
|
||||
}
|
||||
|
||||
private static write(record: ConnectionOutageRecord): void {
|
||||
customSessionStorage.setItem(this.buildKey(record.projectId), record)
|
||||
}
|
||||
|
||||
// Sweep every user's records on logout. sessionStorage is per-tab and can't
|
||||
// be reached from the tab that handles logout for any other tab, so we clear
|
||||
// the whole prefix here rather than scoping to the current user.
|
||||
static clearAll(): void {
|
||||
customSessionStorage.removeByPrefix(KEY_PREFIX)
|
||||
}
|
||||
|
||||
// Begin an outage. No-op if a record already exists, preserving the original
|
||||
// timestamps (epoch ms) across reloads and repeated stalled ticks.
|
||||
static start(projectId: string, oldestUnsavedOpAt: number | null): void {
|
||||
if (this.read(projectId)) {
|
||||
return
|
||||
}
|
||||
this.write({
|
||||
projectId,
|
||||
detectedAt: Date.now(),
|
||||
oldestUnsavedOpAt,
|
||||
pendingChars: 0,
|
||||
inflightChars: 0,
|
||||
updatedAt: Date.now(),
|
||||
})
|
||||
}
|
||||
|
||||
// Snapshot current unsaved edit sizes while offline. Ops flush on reconnect,
|
||||
// so the live sizes are gone by restore time; persist them here.
|
||||
//
|
||||
// oldestUnsavedOpAt is backfilled the first time an op appears mid-outage:
|
||||
// START runs before the user has typed on an idle drop, so the field is null
|
||||
// then, and unsavedDurationMs would otherwise stay zero even once edits pile up.
|
||||
static recordEdits(
|
||||
projectId: string,
|
||||
pendingChars: number,
|
||||
inflightChars: number,
|
||||
oldestUnsavedOpAt: number | null
|
||||
): void {
|
||||
const record = this.read(projectId)
|
||||
// A torn-down record is final: its edit sizes belong to the teardown moment,
|
||||
// and its updatedAt has to stop moving so the staleness cap can expire it.
|
||||
// The stall never clears after a teardown, so the snapshot interval keeps
|
||||
// running and would otherwise overwrite both.
|
||||
if (!record || record.teardownAt !== undefined) {
|
||||
return
|
||||
}
|
||||
// Each field only ever grows during a genuine stall, because ops cannot
|
||||
// drain without a working connection. A drop therefore means the recovery
|
||||
// flush has begun and the snapshot is measuring that, not the outage: an ack
|
||||
// nulls the inflight op a flush delay before the next pending op is
|
||||
// promoted, so a snapshot landing in that gap reads zero inflight while
|
||||
// pending is still full.
|
||||
record.pendingChars = Math.max(record.pendingChars, pendingChars)
|
||||
record.inflightChars = Math.max(record.inflightChars, inflightChars)
|
||||
if (record.oldestUnsavedOpAt === null && oldestUnsavedOpAt !== null) {
|
||||
record.oldestUnsavedOpAt = oldestUnsavedOpAt
|
||||
}
|
||||
record.updatedAt = Date.now()
|
||||
this.write(record)
|
||||
}
|
||||
|
||||
// Mark an outage as ended by a terminal out-of-sync teardown. Reporting is
|
||||
// left to the hook, which flushes the record as soon as it sees the teardown
|
||||
// and again on the next load, since a teardown that happens while offline
|
||||
// cannot tell that its report was dropped.
|
||||
//
|
||||
// Upserts, because a doc error unrelated to connectivity gets here before any
|
||||
// stall is detected. Those records have no unsaved op timestamp, which is what
|
||||
// separates them from a genuine outage.
|
||||
static recordTeardown(projectId: string): void {
|
||||
const record = this.read(projectId) ?? {
|
||||
projectId,
|
||||
detectedAt: Date.now(),
|
||||
oldestUnsavedOpAt: null,
|
||||
pendingChars: 0,
|
||||
inflightChars: 0,
|
||||
updatedAt: Date.now(),
|
||||
}
|
||||
if (record.teardownAt !== undefined) {
|
||||
return
|
||||
}
|
||||
record.teardownAt = Date.now()
|
||||
record.updatedAt = Date.now()
|
||||
this.write(record)
|
||||
}
|
||||
}
|
||||
@@ -86,7 +86,7 @@ function getOpSize(op: AnyOperation) {
|
||||
return 0
|
||||
}
|
||||
|
||||
function getShareJsOpSize(shareJsOp: ShareJsOperation) {
|
||||
export function getShareJsOpSize(shareJsOp: ShareJsOperation) {
|
||||
return shareJsOp.reduce((total, op) => total + getOpSize(op), 0)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
// Migrated from static methods of Document in Document.js
|
||||
|
||||
import { DocumentContainer } from '@/features/ide-react/editor/document-container'
|
||||
import {
|
||||
DocumentContainer,
|
||||
getShareJsOpSize,
|
||||
} from '@/features/ide-react/editor/document-container'
|
||||
import { debugConsole } from '@/utils/debugging'
|
||||
import { Socket } from '@/features/ide-react/connection/types/socket'
|
||||
import { IdeEventEmitter } from '@/features/ide-react/create-ide-event-emitter'
|
||||
@@ -45,18 +48,37 @@ export class OpenDocuments {
|
||||
const docs = this.unsavedDocs()
|
||||
let pendingOpsLength = 0
|
||||
let inflightOpsLength = 0
|
||||
let pendingChars = 0
|
||||
let inflightChars = 0
|
||||
for (const doc of docs) {
|
||||
const pendingOp = doc.getPendingOp()
|
||||
const inFlightOp = doc.getInflightOp()
|
||||
pendingOpsLength += pendingOp?.length || 0
|
||||
inflightOpsLength += inFlightOp?.length || 0
|
||||
pendingChars += pendingOp ? getShareJsOpSize(pendingOp) : 0
|
||||
inflightChars += inFlightOp ? getShareJsOpSize(inFlightOp) : 0
|
||||
}
|
||||
return {
|
||||
pendingOpsLength,
|
||||
inflightOpsLength,
|
||||
pendingChars,
|
||||
inflightChars,
|
||||
}
|
||||
}
|
||||
|
||||
// performance.now() timeline, matching the unsaved-docs alert
|
||||
getOldestUnsavedOpCreatedAt(): number | null {
|
||||
let oldest: number | null = null
|
||||
for (const doc of this.unsavedDocs()) {
|
||||
const createdAt =
|
||||
doc.getInflightOpCreatedAt() ?? doc.getPendingOpCreatedAt()
|
||||
if (createdAt != null && (oldest === null || createdAt < oldest)) {
|
||||
oldest = createdAt
|
||||
}
|
||||
}
|
||||
return oldest
|
||||
}
|
||||
|
||||
private createDoc(docId: string) {
|
||||
const doc = new DocumentContainer(
|
||||
docId,
|
||||
|
||||
@@ -0,0 +1,313 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { sendMB } from '@/infrastructure/event-tracking'
|
||||
import { isSplitTestEnabled } from '@/utils/splitTestUtils'
|
||||
import { useIsNetworkStalledState } from '@/features/ide-react/hooks/use-is-network-stalled'
|
||||
import {
|
||||
ConnectionOutageTracker,
|
||||
type ConnectionOutageRecord,
|
||||
type OutageResolution,
|
||||
} from '@/features/ide-react/editor/connection-outage-tracker'
|
||||
import { useConnectionContext } from '@/features/ide-react/context/connection-context'
|
||||
import { useEditorManagerContext } from '@/features/ide-react/context/editor-manager-context'
|
||||
import { useIdeReactContext } from '@/features/ide-react/context/ide-react-context'
|
||||
import { useProjectContext } from '@/shared/context/project-context'
|
||||
import type { IdeEvents } from '@/features/ide-react/create-ide-event-emitter'
|
||||
import type { OpenDocuments } from '@/features/ide-react/editor/open-documents'
|
||||
|
||||
const OUTAGE_THRESHOLD_MS = 30_000
|
||||
const EDIT_SNAPSHOT_INTERVAL_MS = 2_000
|
||||
const MAX_RELOAD_RESTORE_MS = 24 * 60 * 60 * 1000
|
||||
|
||||
const offlineBackupEnabled = isSplitTestEnabled(
|
||||
'intermittent-connection-improvements'
|
||||
)
|
||||
|
||||
type OutageProps = {
|
||||
detectedAt: number
|
||||
unsavedDurationMs: number
|
||||
outageDurationMs: number
|
||||
pendingChars: number
|
||||
inflightChars: number
|
||||
}
|
||||
|
||||
// unsavedDurationMs runs from the oldest unacknowledged local op and is 0 when
|
||||
// the outage carried no edits at all. outageDurationMs stays separate so that
|
||||
// "long outage but no work at risk" and "long outage with unsaved work" are
|
||||
// distinguishable in the data.
|
||||
//
|
||||
// detectedAt identifies the outage: it is set once and frozen for the record's
|
||||
// lifetime, so user + page + detectedAt deduplicates a teardown reported both
|
||||
// inline and again on the next load. It is a client clock, so comparing it
|
||||
// against server-side event timestamps needs skew tolerance, and for a doc
|
||||
// error that never stalled it is the teardown time rather than an outage onset
|
||||
// (those rows have an outageDurationMs near zero).
|
||||
function readOutageProps(
|
||||
record: ConnectionOutageRecord,
|
||||
endedAt = Date.now()
|
||||
): OutageProps {
|
||||
return {
|
||||
detectedAt: record.detectedAt,
|
||||
unsavedDurationMs:
|
||||
record.oldestUnsavedOpAt === null
|
||||
? 0
|
||||
: Math.floor(endedAt - record.oldestUnsavedOpAt),
|
||||
outageDurationMs: Math.floor(endedAt - record.detectedAt),
|
||||
pendingChars: record.pendingChars,
|
||||
inflightChars: record.inflightChars,
|
||||
}
|
||||
}
|
||||
|
||||
// Op timestamps are on the performance.now() timeline, but the record survives
|
||||
// reloads, so convert to epoch ms.
|
||||
function getOldestUnsavedOpAt(openDocs: OpenDocuments): number | null {
|
||||
const oldestOpCreatedAt = openDocs.getOldestUnsavedOpCreatedAt()
|
||||
if (oldestOpCreatedAt === null) {
|
||||
return null
|
||||
}
|
||||
return Date.now() - (performance.now() - oldestOpCreatedAt)
|
||||
}
|
||||
|
||||
// spannedReload is independent of the resolution: a reload during the outage
|
||||
// discards the in-memory op queue, so the work only survived if the offline
|
||||
// backup held it, however the outage went on to end.
|
||||
function emitOutageEnded(
|
||||
record: ConnectionOutageRecord,
|
||||
{
|
||||
resolution,
|
||||
spannedReload,
|
||||
}: { resolution: OutageResolution; spannedReload: boolean }
|
||||
) {
|
||||
const props = readOutageProps(record)
|
||||
if (
|
||||
props.outageDurationMs >= OUTAGE_THRESHOLD_MS ||
|
||||
props.unsavedDurationMs >= OUTAGE_THRESHOLD_MS
|
||||
) {
|
||||
sendMB('connection-restored', {
|
||||
...props,
|
||||
resolution,
|
||||
spannedReload,
|
||||
offlineBackupEnabled,
|
||||
})
|
||||
}
|
||||
return props
|
||||
}
|
||||
|
||||
// Reported whatever the duration: sub-threshold teardowns are doc errors
|
||||
// unrelated to connectivity, and separating those out needs them in the data.
|
||||
function emitTeardown(record: ConnectionOutageRecord, spannedReload: boolean) {
|
||||
const { teardownAt } = record
|
||||
if (teardownAt === undefined) {
|
||||
return null
|
||||
}
|
||||
const props = readOutageProps(record, teardownAt)
|
||||
sendMB('connection-restored', {
|
||||
...props,
|
||||
resolution: 'out-of-sync' satisfies OutageResolution,
|
||||
spannedReload,
|
||||
offlineBackupEnabled,
|
||||
})
|
||||
return props
|
||||
}
|
||||
|
||||
export default function useConnectionOutageTracker(): void {
|
||||
const stalled = useIsNetworkStalledState()
|
||||
const { isConnected, connectionState } = useConnectionContext()
|
||||
const websocketDisconnected =
|
||||
!isConnected || connectionState.reconnectAt !== null
|
||||
const { openDocs } = useEditorManagerContext()
|
||||
const { eventEmitter, outOfSync } = useIdeReactContext()
|
||||
const { projectId } = useProjectContext()
|
||||
|
||||
const prevWebsocketDisconnectedRef = useRef(websocketDisconnected)
|
||||
const prevStalledRef = useRef(stalled)
|
||||
// The outage record is removed as soon as the connection is back, but the
|
||||
// offline edits are only acknowledged (or rejected) some time after that, so
|
||||
// keep the last known outage around to attach to the sync outcome.
|
||||
const lastOutageRef = useRef<OutageProps | null>(null)
|
||||
// The socket starts out CLOSED, so every mount looks disconnected for a beat.
|
||||
// Nothing before the first connection is an outage.
|
||||
const hasConnectedRef = useRef(isConnected)
|
||||
// Set when this load picks up an outage that is still running from a previous
|
||||
// one, so the emit that eventually ends it can report that. Cleared with the
|
||||
// record, so a later outage in the same tab starts from false again.
|
||||
const spannedReloadRef = useRef(false)
|
||||
// The teardown record is deliberately not removed once flushed, so it cannot
|
||||
// double as the "already reported on this load" latch.
|
||||
const teardownFlushedRef = useRef(false)
|
||||
|
||||
// RELOAD survival: Handle recovery after page refresh.
|
||||
//
|
||||
// Declared first so it reads the persisted record before START or SNAPSHOT
|
||||
// can touch it.
|
||||
useEffect(() => {
|
||||
const record = ConnectionOutageTracker.read(projectId)
|
||||
if (!record) return
|
||||
|
||||
// Stale record from before MAX_RELOAD_RESTORE_MS, discard without emitting
|
||||
if (Date.now() - record.updatedAt > MAX_RELOAD_RESTORE_MS) {
|
||||
ConnectionOutageTracker.remove(projectId)
|
||||
return
|
||||
}
|
||||
|
||||
// Ended in a terminal teardown on a previous load. That load may have
|
||||
// flushed it inline already, but had no way to tell whether the beacon
|
||||
// landed, so report it again here and let detectedAt collapse the pair.
|
||||
if (record.teardownAt !== undefined) {
|
||||
lastOutageRef.current = emitTeardown(record, true)
|
||||
ConnectionOutageTracker.remove(projectId)
|
||||
return
|
||||
}
|
||||
|
||||
// Already recovered by mount time, emit and clean up
|
||||
if (!stalled) {
|
||||
lastOutageRef.current = emitOutageEnded(record, {
|
||||
resolution: 'reconnected',
|
||||
spannedReload: true,
|
||||
})
|
||||
ConnectionOutageTracker.remove(projectId)
|
||||
return
|
||||
}
|
||||
|
||||
// Still stalled at mount, seed refs to detect upcoming recovery. This is the
|
||||
// only branch that leaves the outage running, so it is the only one whose
|
||||
// eventual emit has to report that the outage spanned a reload.
|
||||
spannedReloadRef.current = true
|
||||
prevWebsocketDisconnectedRef.current = true
|
||||
prevStalledRef.current = true
|
||||
}, []) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// TEARDOWN: flush a terminal teardown as soon as it happens. The teardown
|
||||
// closes the websocket, but sendMB goes over HTTP, so it still gets through.
|
||||
// Deferring to the next load instead would lose the report whenever the modal
|
||||
// does not force a reload, which the treatment arm's modal never does.
|
||||
//
|
||||
// The record is left in place, because sendMB is fire-and-forget: a teardown
|
||||
// that happens while the machine is genuinely offline has no way of knowing
|
||||
// its beacon was dropped, so the next load has to report it again. That makes
|
||||
// delivery at-least-once, and the duplicate collapses on detectedAt.
|
||||
useEffect(() => {
|
||||
if (!outOfSync || teardownFlushedRef.current) {
|
||||
return
|
||||
}
|
||||
const record = ConnectionOutageTracker.read(projectId)
|
||||
if (!record || record.teardownAt === undefined) {
|
||||
return
|
||||
}
|
||||
teardownFlushedRef.current = true
|
||||
lastOutageRef.current = emitTeardown(record, spannedReloadRef.current)
|
||||
}, [outOfSync, projectId])
|
||||
|
||||
useEffect(() => {
|
||||
if (isConnected) {
|
||||
hasConnectedRef.current = true
|
||||
}
|
||||
}, [isConnected])
|
||||
|
||||
// START: Initialize tracking when stalled.
|
||||
//
|
||||
// Keep websocketDisconnected in the deps: stalled ORs it in, so a drop while
|
||||
// an earlier stall is unresolved leaves stalled true and this would not re-run,
|
||||
// losing the second outage.
|
||||
useEffect(() => {
|
||||
// Bail before reading the docs: that poll mutates per-doc state shared with
|
||||
// the unsaved-docs poll.
|
||||
if (
|
||||
!hasConnectedRef.current ||
|
||||
!stalled ||
|
||||
ConnectionOutageTracker.read(projectId)
|
||||
) {
|
||||
return
|
||||
}
|
||||
ConnectionOutageTracker.start(projectId, getOldestUnsavedOpAt(openDocs))
|
||||
}, [stalled, websocketDisconnected, isConnected, projectId, openDocs])
|
||||
|
||||
// SNAPSHOT: Record edits while offline
|
||||
useEffect(() => {
|
||||
if (!stalled) return
|
||||
|
||||
const snapshot = () => {
|
||||
const { pendingChars, inflightChars } = openDocs.getUnsavedOpsSize()
|
||||
// No docs are open yet on the first tick after a reload, which reads as
|
||||
// zero unsaved work. Ops cannot drain while stalled, so a zero here is
|
||||
// never news and must not overwrite what a previous load recorded.
|
||||
if (pendingChars === 0 && inflightChars === 0) {
|
||||
return
|
||||
}
|
||||
ConnectionOutageTracker.recordEdits(
|
||||
projectId,
|
||||
pendingChars,
|
||||
inflightChars,
|
||||
getOldestUnsavedOpAt(openDocs)
|
||||
)
|
||||
}
|
||||
|
||||
snapshot()
|
||||
const id = window.setInterval(snapshot, EDIT_SNAPSHOT_INTERVAL_MS)
|
||||
|
||||
return () => {
|
||||
window.clearInterval(id)
|
||||
}
|
||||
}, [stalled, projectId, openDocs])
|
||||
|
||||
// FIRE (a): Emit on websocket reconnect. The edits may not have been
|
||||
// acknowledged yet, hence 'reconnected' rather than 'saved'.
|
||||
useEffect(() => {
|
||||
if (
|
||||
prevWebsocketDisconnectedRef.current === true &&
|
||||
websocketDisconnected === false
|
||||
) {
|
||||
const record = ConnectionOutageTracker.read(projectId)
|
||||
if (record && record.teardownAt === undefined) {
|
||||
lastOutageRef.current = emitOutageEnded(record, {
|
||||
resolution: 'reconnected',
|
||||
spannedReload: spannedReloadRef.current,
|
||||
})
|
||||
ConnectionOutageTracker.remove(projectId)
|
||||
spannedReloadRef.current = false
|
||||
}
|
||||
}
|
||||
prevWebsocketDisconnectedRef.current = websocketDisconnected
|
||||
}, [websocketDisconnected, projectId])
|
||||
|
||||
// FIRE (b): Emit when the stall clears without the socket having dropped,
|
||||
// which means the ops were finally acknowledged on the existing connection.
|
||||
useEffect(() => {
|
||||
if (prevStalledRef.current === true && stalled === false) {
|
||||
const record = ConnectionOutageTracker.read(projectId)
|
||||
if (record && record.teardownAt === undefined) {
|
||||
lastOutageRef.current = emitOutageEnded(record, {
|
||||
resolution: 'saved',
|
||||
spannedReload: spannedReloadRef.current,
|
||||
})
|
||||
ConnectionOutageTracker.remove(projectId)
|
||||
spannedReloadRef.current = false
|
||||
}
|
||||
}
|
||||
prevStalledRef.current = stalled
|
||||
}, [stalled, projectId])
|
||||
|
||||
// SYNC OUTCOME: Emit whether the offline edits made it back to the server
|
||||
useEffect(() => {
|
||||
const onSynced = (
|
||||
event: CustomEvent<IdeEvents['ide:offlineChangesSynced']>
|
||||
) => {
|
||||
const [{ docId }] = event.detail
|
||||
sendMB('post-offline-sync-succeeded', { docId })
|
||||
}
|
||||
|
||||
const onFailed = (
|
||||
event: CustomEvent<IdeEvents['ide:unableToSyncOfflineChanges']>
|
||||
) => {
|
||||
const [{ docId }] = event.detail
|
||||
sendMB('post-offline-sync-failed', { docId })
|
||||
}
|
||||
|
||||
eventEmitter.on('ide:offlineChangesSynced', onSynced)
|
||||
eventEmitter.on('ide:unableToSyncOfflineChanges', onFailed)
|
||||
|
||||
return () => {
|
||||
eventEmitter.off('ide:offlineChangesSynced', onSynced)
|
||||
eventEmitter.off('ide:unableToSyncOfflineChanges', onFailed)
|
||||
}
|
||||
}, [eventEmitter, projectId])
|
||||
}
|
||||
@@ -3,12 +3,17 @@ import { useUnsavedDocsContext } from '@/features/ide-react/context/unsaved-docs
|
||||
import { useIdeReactContext } from '@/features/ide-react/context/ide-react-context'
|
||||
import { useConnectionContext } from '../context/connection-context'
|
||||
|
||||
export default function useIsNetworkStalled(): boolean {
|
||||
const enabled = useFeatureFlag('intermittent-connection-improvements')
|
||||
export function useIsNetworkStalledState(): boolean {
|
||||
const { isSavingStalled } = useUnsavedDocsContext()
|
||||
const { outOfSync } = useIdeReactContext()
|
||||
const { isConnected, connectionState } = useConnectionContext()
|
||||
const websocketDisconnected =
|
||||
!isConnected || connectionState.reconnectAt !== null
|
||||
return enabled && (isSavingStalled || outOfSync || websocketDisconnected)
|
||||
return isSavingStalled || outOfSync || websocketDisconnected
|
||||
}
|
||||
|
||||
export default function useIsNetworkStalled(): boolean {
|
||||
const enabled = useFeatureFlag('intermittent-connection-improvements')
|
||||
const stalled = useIsNetworkStalledState()
|
||||
return enabled && stalled
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import { useDsNavStyle } from '@/features/project-list/components/use-is-ds-nav'
|
||||
import { SignOut } from '@phosphor-icons/react'
|
||||
import ThemeToggle from '@/features/project-list/components/sidebar/theme-toggle'
|
||||
import { OfflineDocBackup } from '@/features/ide-react/editor/offline-doc-backup'
|
||||
import { ConnectionOutageTracker } from '@/features/ide-react/editor/connection-outage-tracker'
|
||||
|
||||
export function AccountMenuItems({
|
||||
sessionUser,
|
||||
@@ -66,7 +67,10 @@ export function AccountMenuItems({
|
||||
id={logOutFormId}
|
||||
method="POST"
|
||||
action="/logout"
|
||||
onSubmit={() => OfflineDocBackup.clearAll()}
|
||||
onSubmit={() => {
|
||||
OfflineDocBackup.clearAll()
|
||||
ConnectionOutageTracker.clearAll()
|
||||
}}
|
||||
>
|
||||
<input type="hidden" name="_csrf" value={getMeta('ol-csrfToken')} />
|
||||
</form>
|
||||
|
||||
+859
@@ -0,0 +1,859 @@
|
||||
import { FC, useRef, useState } from 'react'
|
||||
import sinon from 'sinon'
|
||||
import { ConnectionContext } from '@/features/ide-react/context/connection-context'
|
||||
import { IdeReactContext } from '@/features/ide-react/context/ide-react-context'
|
||||
import { UnsavedDocsContext } from '@/features/ide-react/context/unsaved-docs-context'
|
||||
import { EditorManagerContext } from '@/features/ide-react/context/editor-manager-context'
|
||||
import { ProjectContext } from '@/shared/context/project-context'
|
||||
import customSessionStorage from '@/infrastructure/session-storage'
|
||||
import {
|
||||
ConnectionOutageTracker,
|
||||
type ConnectionOutageRecord,
|
||||
} from '@/features/ide-react/editor/connection-outage-tracker'
|
||||
import useConnectionOutageTracker from '@/features/ide-react/hooks/use-connection-outage-tracker'
|
||||
import { IdeEventEmitter } from '@/features/ide-react/create-ide-event-emitter'
|
||||
|
||||
const PROJECT_ID = 'project-123'
|
||||
|
||||
type Signals = {
|
||||
isConnected: boolean
|
||||
reconnectAt: number | null
|
||||
isSavingStalled: boolean
|
||||
outOfSync: boolean
|
||||
pendingChars: number
|
||||
inflightChars: number
|
||||
// age of the oldest unacknowledged local op at the time it is read
|
||||
oldestOpAgeMs: number | null
|
||||
}
|
||||
|
||||
const INITIAL: Signals = {
|
||||
isConnected: true,
|
||||
reconnectAt: null,
|
||||
isSavingStalled: false,
|
||||
outOfSync: false,
|
||||
pendingChars: 0,
|
||||
inflightChars: 0,
|
||||
oldestOpAgeMs: null,
|
||||
}
|
||||
|
||||
type Controller = {
|
||||
set: (patch: Partial<Signals>) => void
|
||||
eventEmitter: IdeEventEmitter
|
||||
}
|
||||
|
||||
const HookHost: FC = () => {
|
||||
useConnectionOutageTracker()
|
||||
return null
|
||||
}
|
||||
|
||||
function mount(controller: Controller, initial: Partial<Signals> = {}) {
|
||||
const Harness: FC = () => {
|
||||
const [signals, setSignals] = useState<Signals>({ ...INITIAL, ...initial })
|
||||
const signalsRef = useRef(signals)
|
||||
signalsRef.current = signals
|
||||
controller.set = patch => setSignals(s => ({ ...s, ...patch }))
|
||||
|
||||
const [eventEmitter] = useState(() => new IdeEventEmitter())
|
||||
controller.eventEmitter = eventEmitter
|
||||
|
||||
const [openDocs] = useState(() => ({
|
||||
getUnsavedOpsSize: () => ({
|
||||
pendingChars: signalsRef.current.pendingChars,
|
||||
inflightChars: signalsRef.current.inflightChars,
|
||||
}),
|
||||
getOldestUnsavedOpCreatedAt: () => {
|
||||
const { oldestOpAgeMs } = signalsRef.current
|
||||
if (oldestOpAgeMs === null) {
|
||||
return null
|
||||
}
|
||||
return performance.now() - oldestOpAgeMs
|
||||
},
|
||||
}))
|
||||
|
||||
const websocketDisconnected =
|
||||
!signals.isConnected || signals.reconnectAt !== null
|
||||
const stalled =
|
||||
signals.isSavingStalled || signals.outOfSync || websocketDisconnected
|
||||
|
||||
const connectionValue = {
|
||||
isConnected: signals.isConnected,
|
||||
connectionState: { reconnectAt: signals.reconnectAt },
|
||||
}
|
||||
const ideReactValue = { outOfSync: signals.outOfSync, eventEmitter }
|
||||
const unsavedValue = { isSavingStalled: signals.isSavingStalled }
|
||||
const editorManagerValue = { openDocs }
|
||||
const projectValue = { projectId: PROJECT_ID }
|
||||
|
||||
// These testids expose the currently-committed signal values so tests can
|
||||
// block on `.should('have.text', ...)` before advancing the fake clock.
|
||||
// Without that sync, cy.tick can fire timers before React has flushed a
|
||||
// preceding setState, leaving effect/interval callbacks reading stale state.
|
||||
return (
|
||||
<ConnectionContext.Provider value={connectionValue as any}>
|
||||
<IdeReactContext.Provider value={ideReactValue as any}>
|
||||
<UnsavedDocsContext.Provider value={unsavedValue as any}>
|
||||
<EditorManagerContext.Provider value={editorManagerValue as any}>
|
||||
<ProjectContext.Provider value={projectValue as any}>
|
||||
<div data-testid="stalled">{String(stalled)}</div>
|
||||
<div data-testid="isConnected">
|
||||
{String(signals.isConnected)}
|
||||
</div>
|
||||
<div data-testid="pendingChars">
|
||||
{String(signals.pendingChars)}
|
||||
</div>
|
||||
<div data-testid="oldestOpAgeMs">
|
||||
{String(signals.oldestOpAgeMs)}
|
||||
</div>
|
||||
<HookHost />
|
||||
</ProjectContext.Provider>
|
||||
</EditorManagerContext.Provider>
|
||||
</UnsavedDocsContext.Provider>
|
||||
</IdeReactContext.Provider>
|
||||
</ConnectionContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
cy.mount(<Harness />)
|
||||
}
|
||||
|
||||
const BEACON_URL = '/event/connection-restored'
|
||||
const SYNCED_BEACON_URL = '/event/post-offline-sync-succeeded'
|
||||
const FAILED_BEACON_URL = '/event/post-offline-sync-failed'
|
||||
|
||||
// sendMB emits via navigator.sendBeacon internally. In webpack CT the named
|
||||
// import of sendMB cannot be stubbed (live binding), so intercept at the
|
||||
// browser boundary instead. Returns the segmentation parsed from the Blob body
|
||||
// of the beacon call for the given event URL.
|
||||
function beaconSegmentation(
|
||||
url: string
|
||||
): Cypress.Chainable<Record<string, unknown>> {
|
||||
return cy
|
||||
.get('@sendBeacon')
|
||||
.should(stub => {
|
||||
const call = (stub as unknown as sinon.SinonStub)
|
||||
.getCalls()
|
||||
.find(({ args }) => args[0] === url)
|
||||
expect(call, `beacon to ${url}`).to.exist
|
||||
})
|
||||
.then(stub => {
|
||||
const call = (stub as unknown as sinon.SinonStub)
|
||||
.getCalls()
|
||||
.find(({ args }) => args[0] === url)!
|
||||
const [, blob] = call.args
|
||||
return cy
|
||||
.wrap((blob as Blob).text())
|
||||
.then(text => JSON.parse(text as string))
|
||||
})
|
||||
}
|
||||
|
||||
describe('useConnectionOutageTracker', function () {
|
||||
beforeEach(function () {
|
||||
// Back customSessionStorage with an in-memory store so persistence is
|
||||
// deterministic and independent of the CT iframe's real sessionStorage.
|
||||
const store = new Map<string, string>()
|
||||
cy.stub(customSessionStorage, 'getItem').callsFake((key: string) => {
|
||||
const value = store.get(key)
|
||||
return value === undefined ? null : JSON.parse(value)
|
||||
})
|
||||
cy.stub(customSessionStorage, 'setItem').callsFake(
|
||||
(key: string, value: unknown) => {
|
||||
store.set(key, JSON.stringify(value))
|
||||
}
|
||||
)
|
||||
cy.stub(customSessionStorage, 'removeItem').callsFake((key: string) => {
|
||||
store.delete(key)
|
||||
})
|
||||
cy.stub(customSessionStorage, 'removeByPrefix').callsFake(
|
||||
(prefix: string) => {
|
||||
for (const key of [...store.keys()]) {
|
||||
if (key.startsWith(prefix)) {
|
||||
store.delete(key)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
// sendBeacon short-circuits unless isOverleaf is set; CT's resetMeta omits
|
||||
// it. Set it plus a csrf token so the real emit path runs into our stub.
|
||||
window.metaAttributesCache.get('ol-ExposedSettings').isOverleaf = true
|
||||
window.metaAttributesCache.set('ol-csrfToken', 'test-csrf')
|
||||
|
||||
ConnectionOutageTracker.clearAll()
|
||||
cy.clock()
|
||||
cy.stub(window.navigator, 'sendBeacon').as('sendBeacon')
|
||||
})
|
||||
|
||||
afterEach(function () {
|
||||
ConnectionOutageTracker.clearAll()
|
||||
})
|
||||
|
||||
it('emits connection-restored once on websocket reconnect after >= 30s offline', function () {
|
||||
const controller = {} as Controller
|
||||
mount(controller)
|
||||
|
||||
cy.then(() =>
|
||||
controller.set({
|
||||
isConnected: false,
|
||||
pendingChars: 5,
|
||||
inflightChars: 3,
|
||||
oldestOpAgeMs: 0,
|
||||
})
|
||||
)
|
||||
cy.tick(1) // flush START + SNAPSHOT passive effects
|
||||
cy.findByTestId('stalled').should('have.text', 'true')
|
||||
cy.tick(31_000)
|
||||
cy.then(() => controller.set({ isConnected: true }))
|
||||
cy.tick(1) // flush FIRE passive effect
|
||||
cy.findByTestId('stalled').should('have.text', 'false')
|
||||
|
||||
cy.get('@sendBeacon').should('have.been.calledOnce')
|
||||
cy.get('@sendBeacon').should('have.been.calledWith', BEACON_URL)
|
||||
beaconSegmentation(BEACON_URL).should(segmentation => {
|
||||
expect(segmentation).to.include({
|
||||
resolution: 'reconnected',
|
||||
spannedReload: false,
|
||||
pendingChars: 5,
|
||||
inflightChars: 3,
|
||||
})
|
||||
expect(segmentation.unsavedDurationMs).to.be.at.least(31_000)
|
||||
expect(segmentation.outageDurationMs).to.be.at.least(31_000)
|
||||
})
|
||||
})
|
||||
|
||||
it('emits an idle outage with unsavedDurationMs: 0 so no-edit blips still count', function () {
|
||||
// Socket drops with no ops pending and none arrive during the outage.
|
||||
// The record's oldestUnsavedOpAt stays null, and unsavedDurationMs must be 0
|
||||
// rather than falling back to detectedAt (which would misreport 31s of work
|
||||
// at risk when there was none).
|
||||
const controller = {} as Controller
|
||||
mount(controller)
|
||||
|
||||
cy.then(() => controller.set({ isConnected: false }))
|
||||
cy.tick(1) // flush START passive effect
|
||||
cy.findByTestId('stalled').should('have.text', 'true')
|
||||
cy.tick(31_000)
|
||||
cy.then(() => controller.set({ isConnected: true }))
|
||||
cy.tick(1) // flush FIRE passive effect
|
||||
|
||||
cy.get('@sendBeacon').should('have.been.calledOnce')
|
||||
beaconSegmentation(BEACON_URL).should(segmentation => {
|
||||
expect(segmentation).to.include({
|
||||
resolution: 'reconnected',
|
||||
unsavedDurationMs: 0,
|
||||
pendingChars: 0,
|
||||
inflightChars: 0,
|
||||
})
|
||||
expect(segmentation.outageDurationMs).to.be.at.least(31_000)
|
||||
})
|
||||
})
|
||||
|
||||
it('backfills oldestUnsavedOpAt from the first edit that arrives mid-outage', function () {
|
||||
// Socket drops before the user types anything, so START captures null. Once
|
||||
// an op appears, recordEdits backfills the timestamp; unsavedDurationMs is
|
||||
// then measured from that first edit, not from detectedAt.
|
||||
const controller = {} as Controller
|
||||
mount(controller)
|
||||
|
||||
cy.then(() => controller.set({ isConnected: false }))
|
||||
cy.tick(1) // flush START passive effect
|
||||
cy.findByTestId('stalled').should('have.text', 'true')
|
||||
cy.then(() => {
|
||||
expect(
|
||||
ConnectionOutageTracker.read(PROJECT_ID)?.oldestUnsavedOpAt
|
||||
).to.equal(null)
|
||||
})
|
||||
|
||||
// 10s of idle outage, then the user types
|
||||
cy.tick(10_000)
|
||||
cy.then(() =>
|
||||
controller.set({ pendingChars: 4, inflightChars: 0, oldestOpAgeMs: 0 })
|
||||
)
|
||||
// Force React to commit before the interval fires, otherwise the snapshot
|
||||
// callback reads a stale signalsRef and skips backfill.
|
||||
cy.findByTestId('pendingChars').should('have.text', '4')
|
||||
cy.tick(2_000) // let SNAPSHOT interval fire and backfill
|
||||
cy.wrap(null).should(() => {
|
||||
const stored = ConnectionOutageTracker.read(PROJECT_ID)
|
||||
expect(stored?.oldestUnsavedOpAt).to.not.equal(null)
|
||||
expect(stored?.pendingChars).to.equal(4)
|
||||
})
|
||||
|
||||
// 21s more, still stalled, then reconnect
|
||||
cy.tick(21_000)
|
||||
cy.then(() => controller.set({ isConnected: true }))
|
||||
cy.tick(1) // flush FIRE passive effect
|
||||
|
||||
beaconSegmentation(BEACON_URL).should(segmentation => {
|
||||
// ~21s from the first edit, not ~31s from the drop
|
||||
expect(segmentation.unsavedDurationMs).to.be.at.least(21_000)
|
||||
expect(segmentation.unsavedDurationMs).to.be.lessThan(31_000)
|
||||
expect(segmentation.outageDurationMs).to.be.at.least(31_000)
|
||||
})
|
||||
})
|
||||
|
||||
it('does not overwrite oldestUnsavedOpAt captured at START with a later edit', function () {
|
||||
// Socket drops with a 20s-old op already pending. START captures that
|
||||
// timestamp, and a fresh op arriving later must not push it forward.
|
||||
const controller = {} as Controller
|
||||
mount(controller)
|
||||
|
||||
cy.then(() =>
|
||||
controller.set({
|
||||
isConnected: false,
|
||||
pendingChars: 2,
|
||||
oldestOpAgeMs: 20_000,
|
||||
})
|
||||
)
|
||||
// Force React to commit before ticking so START reads oldestOpAgeMs=20_000
|
||||
cy.findByTestId('oldestOpAgeMs').should('have.text', '20000')
|
||||
cy.tick(11_000)
|
||||
cy.then(() => controller.set({ pendingChars: 6, oldestOpAgeMs: 0 }))
|
||||
cy.findByTestId('pendingChars').should('have.text', '6')
|
||||
cy.tick(2_000) // let SNAPSHOT interval fire once
|
||||
cy.then(() => controller.set({ isConnected: true }))
|
||||
cy.tick(1)
|
||||
|
||||
beaconSegmentation(BEACON_URL).should(segmentation => {
|
||||
// From the original 20s-old op, plus the ~13s of outage
|
||||
expect(segmentation.unsavedDurationMs).to.be.at.least(33_000)
|
||||
})
|
||||
})
|
||||
|
||||
it('emits a terminal teardown right away while the network is up', function () {
|
||||
const controller = {} as Controller
|
||||
mount(controller)
|
||||
|
||||
cy.then(() =>
|
||||
controller.set({ isConnected: false, pendingChars: 9, inflightChars: 4 })
|
||||
)
|
||||
cy.tick(1) // flush START + SNAPSHOT passive effects
|
||||
cy.findByTestId('stalled').should('have.text', 'true')
|
||||
cy.tick(46_000)
|
||||
cy.then(() => {
|
||||
ConnectionOutageTracker.recordTeardown(PROJECT_ID)
|
||||
controller.set({ outOfSync: true })
|
||||
})
|
||||
cy.tick(1)
|
||||
|
||||
// The teardown only closes the websocket, and sendMB goes over HTTP
|
||||
cy.get('@sendBeacon').should('have.been.calledOnce')
|
||||
beaconSegmentation(BEACON_URL).should(segmentation => {
|
||||
expect(segmentation).to.include({
|
||||
resolution: 'out-of-sync',
|
||||
spannedReload: false,
|
||||
pendingChars: 9,
|
||||
inflightChars: 4,
|
||||
})
|
||||
expect(segmentation.detectedAt).to.equal(
|
||||
ConnectionOutageTracker.read(PROJECT_ID)?.detectedAt
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps the teardown record after an inline flush so the next load can retry', function () {
|
||||
const controller = {} as Controller
|
||||
mount(controller)
|
||||
|
||||
cy.then(() =>
|
||||
controller.set({ isConnected: false, pendingChars: 9, inflightChars: 4 })
|
||||
)
|
||||
cy.tick(1) // flush START + SNAPSHOT passive effects
|
||||
cy.findByTestId('stalled').should('have.text', 'true')
|
||||
cy.tick(46_000)
|
||||
cy.then(() => {
|
||||
ConnectionOutageTracker.recordTeardown(PROJECT_ID)
|
||||
controller.set({ outOfSync: true })
|
||||
})
|
||||
cy.tick(1)
|
||||
|
||||
// The stall never clears after a teardown, so the snapshot interval keeps
|
||||
// running against a record that has to stay frozen and must not be
|
||||
// reported a second time on this load
|
||||
cy.tick(31_000)
|
||||
cy.then(() => {
|
||||
const record = ConnectionOutageTracker.read(PROJECT_ID)
|
||||
expect(record).to.not.equal(null)
|
||||
expect(record?.teardownAt).to.not.equal(undefined)
|
||||
expect(record?.pendingChars).to.equal(9)
|
||||
})
|
||||
cy.get('@sendBeacon').should('have.been.calledOnce')
|
||||
})
|
||||
|
||||
it('emits a deferred teardown on the next load, timed to the teardown', function () {
|
||||
const controller = {} as Controller
|
||||
cy.then(() => {
|
||||
const now = Date.now()
|
||||
const record: ConnectionOutageRecord = {
|
||||
projectId: PROJECT_ID,
|
||||
detectedAt: now - 50_000,
|
||||
oldestUnsavedOpAt: now - 60_000,
|
||||
pendingChars: 9,
|
||||
inflightChars: 4,
|
||||
updatedAt: now - 1_000,
|
||||
teardownAt: now - 5_000,
|
||||
}
|
||||
customSessionStorage.setItem(
|
||||
ConnectionOutageTracker.buildKey(PROJECT_ID),
|
||||
record
|
||||
)
|
||||
})
|
||||
mount(controller)
|
||||
cy.tick(1) // flush RELOAD-survival mount effect
|
||||
|
||||
cy.get('@sendBeacon').should('have.been.calledOnce')
|
||||
beaconSegmentation(BEACON_URL).should(segmentation => {
|
||||
expect(segmentation).to.include({
|
||||
resolution: 'out-of-sync',
|
||||
spannedReload: true,
|
||||
pendingChars: 9,
|
||||
inflightChars: 4,
|
||||
})
|
||||
// measured to the teardown, not to the flush
|
||||
expect(segmentation.unsavedDurationMs).to.equal(55_000)
|
||||
expect(segmentation.outageDurationMs).to.equal(45_000)
|
||||
})
|
||||
cy.then(() => {
|
||||
expect(ConnectionOutageTracker.read(PROJECT_ID)).to.equal(null)
|
||||
})
|
||||
})
|
||||
|
||||
it('emits a teardown that followed no outage, so cold doc errors stay visible', function () {
|
||||
const controller = {} as Controller
|
||||
cy.then(() => {
|
||||
const now = Date.now()
|
||||
const record: ConnectionOutageRecord = {
|
||||
projectId: PROJECT_ID,
|
||||
detectedAt: now,
|
||||
oldestUnsavedOpAt: null,
|
||||
pendingChars: 0,
|
||||
inflightChars: 0,
|
||||
updatedAt: now,
|
||||
teardownAt: now,
|
||||
}
|
||||
customSessionStorage.setItem(
|
||||
ConnectionOutageTracker.buildKey(PROJECT_ID),
|
||||
record
|
||||
)
|
||||
})
|
||||
mount(controller)
|
||||
cy.tick(1) // flush RELOAD-survival mount effect
|
||||
|
||||
// Sent despite being far below the 30s threshold
|
||||
cy.get('@sendBeacon').should('have.been.calledOnce')
|
||||
beaconSegmentation(BEACON_URL).should(segmentation => {
|
||||
expect(segmentation).to.include({
|
||||
resolution: 'out-of-sync',
|
||||
})
|
||||
expect(segmentation.unsavedDurationMs).to.equal(0)
|
||||
})
|
||||
})
|
||||
|
||||
it('does not emit when out-of-sync is set without a recorded teardown', function () {
|
||||
const controller = {} as Controller
|
||||
mount(controller)
|
||||
|
||||
cy.tick(31_000)
|
||||
cy.then(() => controller.set({ outOfSync: true }))
|
||||
cy.tick(1)
|
||||
|
||||
cy.get('@sendBeacon').should('not.have.been.called')
|
||||
})
|
||||
|
||||
it('records the second outage when a drop follows a reconnect mid-stall', function () {
|
||||
const controller = {} as Controller
|
||||
mount(controller)
|
||||
|
||||
// First outage: drop while saving is already stalled
|
||||
cy.then(() => controller.set({ isConnected: false, isSavingStalled: true }))
|
||||
cy.tick(1) // flush START + SNAPSHOT passive effects
|
||||
cy.findByTestId('stalled').should('have.text', 'true')
|
||||
cy.tick(31_000)
|
||||
|
||||
// Reconnect, but the ops are still unacknowledged so stalled stays true
|
||||
cy.then(() => controller.set({ isConnected: true }))
|
||||
cy.findByTestId('isConnected').should('have.text', 'true')
|
||||
cy.get('@sendBeacon').should('have.been.calledOnce')
|
||||
|
||||
// Second drop: stalled never changed, so only websocketDisconnected can
|
||||
// re-arm tracking here
|
||||
cy.then(() => controller.set({ isConnected: false }))
|
||||
cy.findByTestId('isConnected').should('have.text', 'false')
|
||||
cy.wrap(null).should(() => {
|
||||
expect(ConnectionOutageTracker.read(PROJECT_ID)).to.not.equal(null)
|
||||
})
|
||||
cy.tick(31_000)
|
||||
cy.then(() => controller.set({ isConnected: true }))
|
||||
cy.tick(1)
|
||||
|
||||
cy.get('@sendBeacon').should('have.been.calledTwice')
|
||||
})
|
||||
|
||||
it('counts the outage from the oldest unsaved op, not from when the stall was detected', function () {
|
||||
const controller = {} as Controller
|
||||
mount(controller)
|
||||
|
||||
cy.then(() =>
|
||||
controller.set({ isSavingStalled: true, oldestOpAgeMs: 20_000 })
|
||||
)
|
||||
cy.tick(1) // flush START + SNAPSHOT passive effects
|
||||
cy.findByTestId('stalled').should('have.text', 'true')
|
||||
cy.tick(15_000)
|
||||
cy.then(() => controller.set({ isSavingStalled: false }))
|
||||
cy.tick(1) // flush FIRE passive effect
|
||||
|
||||
cy.get('@sendBeacon').should('have.been.calledWith', BEACON_URL)
|
||||
|
||||
// 15s of detected stall, but the oldest op was already 20s old
|
||||
beaconSegmentation(BEACON_URL).should(segmentation => {
|
||||
expect(segmentation.unsavedDurationMs).to.be.at.least(35_000)
|
||||
})
|
||||
})
|
||||
|
||||
it('does not emit when offline for less than 30s', function () {
|
||||
const controller = {} as Controller
|
||||
mount(controller)
|
||||
|
||||
cy.then(() => controller.set({ isConnected: false }))
|
||||
cy.tick(1) // flush START passive effect
|
||||
cy.findByTestId('stalled').should('have.text', 'true')
|
||||
cy.tick(20_000)
|
||||
cy.then(() => controller.set({ isConnected: true }))
|
||||
cy.tick(1) // flush FIRE passive effect
|
||||
cy.findByTestId('stalled').should('have.text', 'false')
|
||||
|
||||
cy.get('@sendBeacon').should('not.have.been.called')
|
||||
})
|
||||
|
||||
it('emits a saved resolution for a saving-stalled-only episode (no websocket drop)', function () {
|
||||
const controller = {} as Controller
|
||||
mount(controller)
|
||||
|
||||
cy.then(() => controller.set({ isSavingStalled: true }))
|
||||
cy.tick(1) // flush START + SNAPSHOT passive effects
|
||||
cy.findByTestId('stalled').should('have.text', 'true')
|
||||
cy.tick(31_000)
|
||||
cy.then(() => controller.set({ isSavingStalled: false }))
|
||||
cy.tick(1) // flush FIRE passive effect
|
||||
cy.findByTestId('stalled').should('have.text', 'false')
|
||||
|
||||
cy.get('@sendBeacon').should('have.been.calledOnce')
|
||||
beaconSegmentation(BEACON_URL).should(segmentation => {
|
||||
expect(segmentation).to.include({ resolution: 'saved' })
|
||||
})
|
||||
})
|
||||
|
||||
it('emits only once when websocket reconnect and full recovery coincide', function () {
|
||||
const controller = {} as Controller
|
||||
mount(controller)
|
||||
|
||||
cy.then(() => controller.set({ isConnected: false, isSavingStalled: true }))
|
||||
cy.tick(1) // flush START + SNAPSHOT passive effects
|
||||
cy.findByTestId('stalled').should('have.text', 'true')
|
||||
cy.tick(31_000)
|
||||
cy.then(() => controller.set({ isConnected: true, isSavingStalled: false }))
|
||||
cy.tick(1) // flush FIRE passive effects
|
||||
cy.findByTestId('stalled').should('have.text', 'false')
|
||||
|
||||
cy.get('@sendBeacon').should('have.been.calledOnce')
|
||||
})
|
||||
|
||||
it('emits on mount when a record recovered during a reload', function () {
|
||||
const controller = {} as Controller
|
||||
cy.then(() => {
|
||||
const now = Date.now()
|
||||
const record: ConnectionOutageRecord = {
|
||||
projectId: PROJECT_ID,
|
||||
detectedAt: now - 60_000,
|
||||
oldestUnsavedOpAt: now - 60_000,
|
||||
pendingChars: 7,
|
||||
inflightChars: 2,
|
||||
updatedAt: now - 1_000,
|
||||
}
|
||||
customSessionStorage.setItem(
|
||||
ConnectionOutageTracker.buildKey(PROJECT_ID),
|
||||
record
|
||||
)
|
||||
})
|
||||
mount(controller)
|
||||
cy.tick(1) // flush RELOAD-survival mount effect
|
||||
|
||||
cy.get('@sendBeacon').should('have.been.calledOnce')
|
||||
cy.get('@sendBeacon').should('have.been.calledWith', BEACON_URL)
|
||||
beaconSegmentation(BEACON_URL).should(segmentation => {
|
||||
expect(segmentation).to.include({
|
||||
resolution: 'reconnected',
|
||||
spannedReload: true,
|
||||
pendingChars: 7,
|
||||
inflightChars: 2,
|
||||
})
|
||||
expect(segmentation.unsavedDurationMs).to.be.at.least(60_000)
|
||||
})
|
||||
})
|
||||
|
||||
it('discards a record older than the reload cap without emitting', function () {
|
||||
const controller = {} as Controller
|
||||
cy.then(() => {
|
||||
const now = Date.now()
|
||||
const record: ConnectionOutageRecord = {
|
||||
projectId: PROJECT_ID,
|
||||
detectedAt: now - 26 * 60 * 60 * 1000,
|
||||
oldestUnsavedOpAt: now - 26 * 60 * 60 * 1000,
|
||||
pendingChars: 4,
|
||||
inflightChars: 1,
|
||||
updatedAt: now - 25 * 60 * 60 * 1000,
|
||||
}
|
||||
customSessionStorage.setItem(
|
||||
ConnectionOutageTracker.buildKey(PROJECT_ID),
|
||||
record
|
||||
)
|
||||
})
|
||||
mount(controller)
|
||||
cy.tick(1) // flush RELOAD-survival mount effect
|
||||
|
||||
cy.get('@sendBeacon').should('not.have.been.called')
|
||||
cy.then(() => {
|
||||
expect(ConnectionOutageTracker.read(PROJECT_ID)).to.equal(null)
|
||||
})
|
||||
})
|
||||
|
||||
// The socket reads as CLOSED on the first render of every load, so the mount
|
||||
// effects all see stalled === true. The reload-survival effect has to read the
|
||||
// persisted record before the snapshot effect can write over it.
|
||||
it('discards a record older than the reload cap even when the mount is stalled', function () {
|
||||
const controller = {} as Controller
|
||||
cy.then(() => {
|
||||
const now = Date.now()
|
||||
const record: ConnectionOutageRecord = {
|
||||
projectId: PROJECT_ID,
|
||||
detectedAt: now - 26 * 60 * 60 * 1000,
|
||||
oldestUnsavedOpAt: now - 26 * 60 * 60 * 1000,
|
||||
pendingChars: 4,
|
||||
inflightChars: 1,
|
||||
updatedAt: now - 25 * 60 * 60 * 1000,
|
||||
}
|
||||
customSessionStorage.setItem(
|
||||
ConnectionOutageTracker.buildKey(PROJECT_ID),
|
||||
record
|
||||
)
|
||||
})
|
||||
// pendingChars is non-zero so that a snapshot taken ahead of the reload
|
||||
// effect would refresh updatedAt and hide the staleness
|
||||
mount(controller, { isConnected: false, pendingChars: 3 })
|
||||
cy.tick(1) // flush mount effects
|
||||
cy.findByTestId('stalled').should('have.text', 'true')
|
||||
|
||||
cy.get('@sendBeacon').should('not.have.been.called')
|
||||
cy.then(() => {
|
||||
expect(ConnectionOutageTracker.read(PROJECT_ID)).to.equal(null)
|
||||
})
|
||||
})
|
||||
|
||||
it('flushes a deferred teardown with the edit sizes recorded before the reload', function () {
|
||||
const controller = {} as Controller
|
||||
cy.then(() => {
|
||||
const now = Date.now()
|
||||
const record: ConnectionOutageRecord = {
|
||||
projectId: PROJECT_ID,
|
||||
detectedAt: now - 50_000,
|
||||
oldestUnsavedOpAt: now - 60_000,
|
||||
pendingChars: 9,
|
||||
inflightChars: 4,
|
||||
updatedAt: now - 1_000,
|
||||
teardownAt: now - 5_000,
|
||||
}
|
||||
customSessionStorage.setItem(
|
||||
ConnectionOutageTracker.buildKey(PROJECT_ID),
|
||||
record
|
||||
)
|
||||
})
|
||||
// No docs are open this early in a load, so the live edit sizes read as zero
|
||||
mount(controller, { outOfSync: true, pendingChars: 0, inflightChars: 0 })
|
||||
cy.tick(1) // flush mount effects
|
||||
cy.findByTestId('stalled').should('have.text', 'true')
|
||||
|
||||
beaconSegmentation(BEACON_URL).should(segmentation => {
|
||||
expect(segmentation).to.include({ pendingChars: 9, inflightChars: 4 })
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps the edit sizes recorded before a reload while the stall continues', function () {
|
||||
const controller = {} as Controller
|
||||
cy.then(() => {
|
||||
const now = Date.now()
|
||||
const record: ConnectionOutageRecord = {
|
||||
projectId: PROJECT_ID,
|
||||
detectedAt: now - 60_000,
|
||||
oldestUnsavedOpAt: now - 60_000,
|
||||
pendingChars: 9,
|
||||
inflightChars: 4,
|
||||
updatedAt: now - 1_000,
|
||||
}
|
||||
customSessionStorage.setItem(
|
||||
ConnectionOutageTracker.buildKey(PROJECT_ID),
|
||||
record
|
||||
)
|
||||
})
|
||||
// Still offline at mount, and no docs open yet, so the live sizes read zero
|
||||
mount(controller, { isConnected: false, pendingChars: 0, inflightChars: 0 })
|
||||
cy.tick(1) // flush mount effects
|
||||
cy.findByTestId('stalled').should('have.text', 'true')
|
||||
cy.get('@sendBeacon').should('not.have.been.called')
|
||||
|
||||
// Let the snapshot interval run a few times before recovering
|
||||
cy.tick(10_000)
|
||||
cy.then(() => controller.set({ isConnected: true }))
|
||||
cy.tick(1) // flush FIRE passive effects
|
||||
|
||||
beaconSegmentation(BEACON_URL).should(segmentation => {
|
||||
expect(segmentation).to.include({ pendingChars: 9, inflightChars: 4 })
|
||||
expect(segmentation.resolution).to.equal('reconnected')
|
||||
// Recovered live on this load, but the outage still began on the previous one
|
||||
expect(segmentation.spannedReload).to.equal(true)
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps the peak edit sizes when the recovery flush drains ops mid-snapshot', function () {
|
||||
const controller = {} as Controller
|
||||
mount(controller)
|
||||
|
||||
cy.then(() =>
|
||||
controller.set({ isConnected: false, pendingChars: 9, inflightChars: 4 })
|
||||
)
|
||||
cy.tick(1) // flush START + SNAPSHOT passive effects
|
||||
cy.findByTestId('stalled').should('have.text', 'true')
|
||||
cy.tick(31_000)
|
||||
|
||||
// An ack nulls the inflight op a flush delay before the next pending op is
|
||||
// promoted, so a snapshot tick here reads zero inflight against a full queue
|
||||
cy.then(() => controller.set({ pendingChars: 5, inflightChars: 0 }))
|
||||
cy.findByTestId('pendingChars').should('have.text', '5')
|
||||
cy.tick(2_000)
|
||||
|
||||
cy.then(() => controller.set({ isConnected: true }))
|
||||
cy.findByTestId('isConnected').should('have.text', 'true')
|
||||
cy.tick(1) // flush FIRE passive effects
|
||||
|
||||
beaconSegmentation(BEACON_URL).should(segmentation => {
|
||||
expect(segmentation).to.include({
|
||||
resolution: 'reconnected',
|
||||
pendingChars: 9,
|
||||
inflightChars: 4,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('ignores a disconnected first render, so an ordinary load records nothing', function () {
|
||||
const controller = {} as Controller
|
||||
mount(controller, { isConnected: false })
|
||||
cy.tick(1) // flush mount effects
|
||||
cy.findByTestId('stalled').should('have.text', 'true')
|
||||
cy.then(() => {
|
||||
expect(ConnectionOutageTracker.read(PROJECT_ID)).to.equal(null)
|
||||
})
|
||||
|
||||
// Not an outage however long the first connection takes
|
||||
cy.tick(31_000)
|
||||
cy.then(() => {
|
||||
expect(ConnectionOutageTracker.read(PROJECT_ID)).to.equal(null)
|
||||
})
|
||||
|
||||
// Connect, then drop for real: that is tracked
|
||||
cy.then(() => controller.set({ isConnected: true }))
|
||||
cy.findByTestId('isConnected').should('have.text', 'true')
|
||||
cy.then(() => controller.set({ isConnected: false }))
|
||||
cy.findByTestId('isConnected').should('have.text', 'false')
|
||||
cy.wrap(null).should(() => {
|
||||
expect(ConnectionOutageTracker.read(PROJECT_ID)).to.not.equal(null)
|
||||
})
|
||||
|
||||
cy.get('@sendBeacon').should('not.have.been.called')
|
||||
})
|
||||
|
||||
it('emits post-offline-sync-succeeded with the doc id after a resolved outage', function () {
|
||||
const controller = {} as Controller
|
||||
mount(controller)
|
||||
|
||||
cy.then(() =>
|
||||
controller.set({
|
||||
isConnected: false,
|
||||
pendingChars: 6,
|
||||
inflightChars: 2,
|
||||
oldestOpAgeMs: 0,
|
||||
})
|
||||
)
|
||||
cy.tick(1) // flush START + SNAPSHOT passive effects
|
||||
cy.findByTestId('stalled').should('have.text', 'true')
|
||||
cy.tick(31_000)
|
||||
cy.then(() => controller.set({ isConnected: true }))
|
||||
cy.tick(1) // flush FIRE passive effect
|
||||
cy.findByTestId('stalled').should('have.text', 'false')
|
||||
|
||||
// The record is gone by now, so the props come from the stash.
|
||||
cy.then(() => {
|
||||
expect(ConnectionOutageTracker.read(PROJECT_ID)).to.equal(null)
|
||||
controller.eventEmitter.emit('ide:offlineChangesSynced', {
|
||||
docId: 'doc-1',
|
||||
})
|
||||
})
|
||||
|
||||
beaconSegmentation(SYNCED_BEACON_URL).should(segmentation => {
|
||||
expect(segmentation).to.include({
|
||||
docId: 'doc-1',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('emits post-offline-sync-failed with the doc id while an outage is still ongoing', function () {
|
||||
const controller = {} as Controller
|
||||
mount(controller)
|
||||
|
||||
cy.then(() =>
|
||||
controller.set({
|
||||
isConnected: false,
|
||||
pendingChars: 8,
|
||||
inflightChars: 1,
|
||||
oldestOpAgeMs: 0,
|
||||
})
|
||||
)
|
||||
cy.tick(1) // flush START + SNAPSHOT passive effects
|
||||
cy.findByTestId('stalled').should('have.text', 'true')
|
||||
cy.tick(41_000)
|
||||
|
||||
cy.then(() =>
|
||||
controller.eventEmitter.emit('ide:unableToSyncOfflineChanges', {
|
||||
docId: 'doc-2',
|
||||
editorContent: 'offline edits',
|
||||
baseContent: 'original',
|
||||
docName: 'main.tex',
|
||||
})
|
||||
)
|
||||
|
||||
beaconSegmentation(FAILED_BEACON_URL).should(segmentation => {
|
||||
expect(segmentation).to.include({
|
||||
docId: 'doc-2',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('emits the sync outcome with just the doc when no outage was tracked', function () {
|
||||
const controller = {} as Controller
|
||||
mount(controller)
|
||||
cy.tick(1)
|
||||
|
||||
cy.then(() =>
|
||||
controller.eventEmitter.emit('ide:offlineChangesSynced', {
|
||||
docId: 'doc-3',
|
||||
})
|
||||
)
|
||||
|
||||
cy.get('@sendBeacon').should('have.been.calledOnce')
|
||||
beaconSegmentation(SYNCED_BEACON_URL).should(segmentation => {
|
||||
expect(segmentation).to.include({ docId: 'doc-3' })
|
||||
expect(segmentation).to.not.have.any.keys(
|
||||
'unsavedDurationMs',
|
||||
'pendingChars',
|
||||
'inflightChars'
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user