diff --git a/tracker/tracker-assist/.yarn/install-state.gz b/tracker/tracker-assist/.yarn/install-state.gz new file mode 100644 index 000000000..c9614940e Binary files /dev/null and b/tracker/tracker-assist/.yarn/install-state.gz differ diff --git a/tracker/tracker-assist/.yarnrc.yml b/tracker/tracker-assist/.yarnrc.yml new file mode 100644 index 000000000..3186f3f07 --- /dev/null +++ b/tracker/tracker-assist/.yarnrc.yml @@ -0,0 +1 @@ +nodeLinker: node-modules diff --git a/tracker/tracker-assist/package.json b/tracker/tracker-assist/package.json index df952fc39..2d59076dd 100644 --- a/tracker/tracker-assist/package.json +++ b/tracker/tracker-assist/package.json @@ -1,7 +1,7 @@ { "name": "@openreplay/tracker-assist", "description": "Tracker plugin for screen assistance through the WebRTC", - "version": "9.0.2-beta.3", + "version": "9.0.2-beta.11", "keywords": [ "WebRTC", "assistance", @@ -49,11 +49,12 @@ "prettier": "^2.7.1", "replace-in-files-cli": "^1.0.0", "ts-jest": "^29.0.3", - "typescript": "^4.6.0-dev.20211126" + "typescript": "^5.6.3" }, "lint-staged": { "*.{js,mjs,cjs,jsx,ts,tsx}": [ "eslint --fix --quiet" ] - } + }, + "packageManager": "yarn@4.5.0" } diff --git a/tracker/tracker-assist/src/Assist.ts b/tracker/tracker-assist/src/Assist.ts index 96ea2d895..0e3e2bffd 100644 --- a/tracker/tracker-assist/src/Assist.ts +++ b/tracker/tracker-assist/src/Assist.ts @@ -86,6 +86,7 @@ export default class Assist { private socket: Socket | null = null private peer: Peer | null = null private canvasPeers: Record = {} + private canvasNodeCheckers: Map = new Map() private assistDemandedRestart = false private callingState: CallingState = CallingState.False private remoteControl: RemoteControl | null = null; @@ -674,6 +675,20 @@ export default class Assist { app.debug.error, ) this.canvasMap.set(id, canvasHandler) + if (this.canvasNodeCheckers.has(id)) { + clearInterval(this.canvasNodeCheckers.get(id)) + } + const int = setInterval(() => { + const isPresent = node.ownerDocument.defaultView && node.isConnected + if (!isPresent) { + canvasHandler.stop() + this.canvasMap.delete(id) + this.canvasPeers[id]?.destroy() + this.canvasPeers[id] = null + clearInterval(int) + } + }, 5000) + this.canvasNodeCheckers.set(id, int) } }) } @@ -703,6 +718,10 @@ export default class Assist { this.socket.disconnect() this.app.debug.log('Socket disconnected') } + this.canvasMap.clear() + this.canvasPeers = [] + this.canvasNodeCheckers.forEach((int) => clearInterval(int)) + this.canvasNodeCheckers.clear() } } diff --git a/tracker/tracker-assist/src/version.ts b/tracker/tracker-assist/src/version.ts index 11327ea71..49df217f0 100644 --- a/tracker/tracker-assist/src/version.ts +++ b/tracker/tracker-assist/src/version.ts @@ -1 +1 @@ -export const pkgVersion = "9.0.2-beta.3"; +export const pkgVersion = "9.0.2-beta.11"; diff --git a/tracker/tracker/package.json b/tracker/tracker/package.json index ef590e96d..f0c424457 100644 --- a/tracker/tracker/package.json +++ b/tracker/tracker/package.json @@ -1,7 +1,7 @@ { "name": "@openreplay/tracker", "description": "The OpenReplay tracker main package", - "version": "14.0.11-6", + "version": "14.0.11-30", "keywords": [ "logging", "replay" diff --git a/tracker/tracker/src/main/app/index.ts b/tracker/tracker/src/main/app/index.ts index bf48d3d55..71ab45771 100644 --- a/tracker/tracker/src/main/app/index.ts +++ b/tracker/tracker/src/main/app/index.ts @@ -32,7 +32,7 @@ import Message, { UserID, WSChannel, } from './messages.gen.js' -import Nodes from './nodes.js' +import Nodes from './nodes/index.js' import type { Options as ObserverOptions } from './observer/top_observer.js' import Observer from './observer/top_observer.js' import type { Options as SanitizerOptions } from './sanitizer.js' diff --git a/tracker/tracker/src/main/app/nodes.ts b/tracker/tracker/src/main/app/nodes/index.ts similarity index 79% rename from tracker/tracker/src/main/app/nodes.ts rename to tracker/tracker/src/main/app/nodes/index.ts index 77f4b5dc3..45a1a76a6 100644 --- a/tracker/tracker/src/main/app/nodes.ts +++ b/tracker/tracker/src/main/app/nodes/index.ts @@ -1,20 +1,24 @@ -import { createEventListener, deleteEventListener } from '../utils.js' +import { createEventListener, deleteEventListener } from '../../utils.js' +import Maintainer from './maintainer.js' type NodeCallback = (node: Node, isStart: boolean) => void type ElementListener = [string, EventListener, boolean] export default class Nodes { - private nodes: Array = [] + private readonly nodes: Map = new Map() private totalNodeAmount = 0 private readonly nodeCallbacks: Array = [] private readonly elementListeners: Map> = new Map() private nextNodeId = 0 private readonly node_id: string private readonly forceNgOff: boolean + private readonly maintainer: Maintainer constructor(params: { node_id: string; forceNgOff: boolean }) { this.node_id = params.node_id this.forceNgOff = params.forceNgOff + this.maintainer = new Maintainer(this.nodes, this.unregisterNode) + this.maintainer.start() } syntheticMode(frameOrder: number) { @@ -30,12 +34,12 @@ export default class Nodes { } // Attached once per Tracker instance - attachNodeCallback(nodeCallback: NodeCallback): void { - this.nodeCallbacks.push(nodeCallback) + attachNodeCallback(nodeCallback: NodeCallback): number { + return this.nodeCallbacks.push(nodeCallback) } scanTree = (cb: (node: Node | void) => void) => { - this.nodes.forEach((node) => cb(node)) + this.nodes.forEach((node) => (node ? cb(node) : undefined)) } attachNodeListener = ( @@ -64,18 +68,18 @@ export default class Nodes { id = this.nextNodeId this.totalNodeAmount++ this.nextNodeId++ - this.nodes[id] = node + this.nodes.set(id, node) ;(node as any)[this.node_id] = id } return [id, isNew] } - unregisterNode(node: Node): number | undefined { + unregisterNode = (node: Node): number | undefined => { const id = (node as any)[this.node_id] if (id !== undefined) { ;(node as any)[this.node_id] = undefined delete (node as any)[this.node_id] - delete this.nodes[id] + this.nodes.delete(id) const listeners = this.elementListeners.get(id) if (listeners !== undefined) { this.elementListeners.delete(id) @@ -93,8 +97,7 @@ export default class Nodes { // but its still better than keeping dead nodes or undef elements // plus we keep our index positions for new/alive nodes // performance test: 3ms for 30k nodes with 17k dead ones - for (let i = 0; i < this.nodes.length; i++) { - const node = this.nodes[i] + for (const [_, node] of this.nodes) { if (node && !document.contains(node)) { this.unregisterNode(node) } @@ -111,7 +114,7 @@ export default class Nodes { } getNode(id: number) { - return this.nodes[id] + return this.nodes.get(id) } getNodeCount() { @@ -119,14 +122,13 @@ export default class Nodes { } clear(): void { - for (let id = 0; id < this.nodes.length; id++) { - const node = this.nodes[id] - if (!node) { - continue + for (const [_, node] of this.nodes) { + if (node) { + this.unregisterNode(node) } - this.unregisterNode(node) } + this.nextNodeId = 0 - this.nodes.length = 0 + this.nodes.clear() } } diff --git a/tracker/tracker/src/main/app/nodes/maintainer.ts b/tracker/tracker/src/main/app/nodes/maintainer.ts new file mode 100644 index 000000000..3df725acd --- /dev/null +++ b/tracker/tracker/src/main/app/nodes/maintainer.ts @@ -0,0 +1,82 @@ +const SECOND = 1000 + +function processMapInBatches( + map: Map, + batchSize: number, + processBatchCallback: (node: Node) => void, +) { + const iterator = map.entries() + + function processNextBatch() { + const batch = [] + let result = iterator.next() + + while (!result.done && batch.length < batchSize) { + batch.push(result.value) + result = iterator.next() + } + + if (batch.length > 0) { + batch.forEach(([_, node]) => { + if (node) { + processBatchCallback(node) + } + }) + + setTimeout(processNextBatch, 50) + } + } + + processNextBatch() +} + +function isNodeStillActive(node: Node): boolean { + try { + if (!node.isConnected) { + return false + } + + const nodeWindow = node.ownerDocument?.defaultView + + if (!nodeWindow) { + return false + } + + if (nodeWindow.closed) { + return false + } + + if (!node.ownerDocument.documentElement.isConnected) { + return false + } + + return true + } catch (e) { + console.error('Error checking node activity:', e) + return false + } +} + +class Maintainer { + private interval: ReturnType + constructor( + private readonly nodes: Map, + private readonly unregisterNode: (node: Node) => void, + ) {} + + public start = () => { + this.interval = setInterval(() => { + processMapInBatches(this.nodes, SECOND * 2.5, (node) => { + if (!isNodeStillActive(node)) { + this.unregisterNode(node) + } + }) + }, SECOND * 30) + } + + public stop = () => { + clearInterval(this.interval) + } +} + +export default Maintainer diff --git a/tracker/tracker/src/main/app/observer/iframe_offsets.ts b/tracker/tracker/src/main/app/observer/iframe_offsets.ts index 46e92142c..6263e9438 100644 --- a/tracker/tracker/src/main/app/observer/iframe_offsets.ts +++ b/tracker/tracker/src/main/app/observer/iframe_offsets.ts @@ -8,7 +8,7 @@ type OffsetState = { } export default class IFrameOffsets { - private readonly states: Map = new Map() + private states: WeakMap = new WeakMap() private calcOffset(state: OffsetState): Offset { let parLeft = 0, @@ -55,12 +55,10 @@ export default class IFrameOffsets { // anything more reliable? This does not cover all cases (layout changes are ignored, for ex.) parentDoc.addEventListener('scroll', invalidateOffset) parentDoc.defaultView?.addEventListener('resize', invalidateOffset) - this.states.set(doc, state) } clear() { - this.states.forEach((s) => s.clear()) - this.states.clear() + this.states = new WeakMap() } } diff --git a/tracker/tracker/src/main/app/observer/observer.ts b/tracker/tracker/src/main/app/observer/observer.ts index e177ef740..3533f6509 100644 --- a/tracker/tracker/src/main/app/observer/observer.ts +++ b/tracker/tracker/src/main/app/observer/observer.ts @@ -103,9 +103,6 @@ export default abstract class Observer { if (name === null) { continue } - if (target instanceof HTMLIFrameElement && name === 'src') { - this.handleIframeSrcChange(target) - } let attr = this.attributesMap.get(id) if (attr === undefined) { this.attributesMap.set(id, (attr = new Set())) @@ -132,7 +129,9 @@ export default abstract class Observer { } /** - * Unbinds the removed nodes in case of iframe src change. + * EXPERIMENTAL: Unbinds the removed nodes in case of iframe src change. + * + * right now, we're relying on nodes.maintainer */ private handleIframeSrcChange(iframe: HTMLIFrameElement): void { const oldContentDocument = iframe.contentDocument diff --git a/tracker/tracker/src/main/app/observer/top_observer.ts b/tracker/tracker/src/main/app/observer/top_observer.ts index c3fce79ec..e9e26c76e 100644 --- a/tracker/tracker/src/main/app/observer/top_observer.ts +++ b/tracker/tracker/src/main/app/observer/top_observer.ts @@ -55,7 +55,7 @@ export default class TopObserver extends Observer { private readonly contextCallbacks: Array = [] // Attached once per Tracker instance - private readonly contextsSet: Set = new Set() + private readonly contextsSet: WeakSet = new WeakSet() attachContextCallback(cb: ContextCallback) { this.contextCallbacks.push(cb) } @@ -64,7 +64,7 @@ export default class TopObserver extends Observer { return this.iframeOffsets.getDocumentOffset(doc) } - private iframeObservers: IFrameObserver[] = [] + private iframeObservers: WeakMap = new WeakMap() private handleIframe(iframe: HTMLIFrameElement): void { let doc: Document | null = null // setTimeout is required. Otherwise some event listeners (scroll, mousemove) applied in modules @@ -72,16 +72,12 @@ export default class TopObserver extends Observer { const handle = this.app.safe(() => setTimeout(() => { const id = this.app.nodes.getID(iframe) - if (id === undefined) { - //log - return - } - if (!canAccessIframe(iframe)) return + if (id === undefined || !canAccessIframe(iframe)) return const currentWin = iframe.contentWindow const currentDoc = iframe.contentDocument if (currentDoc && currentDoc !== doc) { const observer = new IFrameObserver(this.app) - this.iframeObservers.push(observer) + this.iframeObservers.set(iframe, observer) observer.observe(iframe) // TODO: call unregisterNode for the previous doc if present (incapsulate: one iframe - one observer) doc = currentDoc @@ -105,10 +101,10 @@ export default class TopObserver extends Observer { handle() } - private shadowRootObservers: ShadowRootObserver[] = [] + private shadowRootObservers: WeakMap = new WeakMap() private handleShadowRoot(shRoot: ShadowRoot) { const observer = new ShadowRootObserver(this.app) - this.shadowRootObservers.push(observer) + this.shadowRootObservers.set(shRoot, observer) observer.observe(shRoot.host) } @@ -152,17 +148,15 @@ export default class TopObserver extends Observer { this.app.nodes.clear() this.app.nodes.syntheticMode(frameOder) const iframeObserver = new IFrameObserver(this.app) - this.iframeObservers.push(iframeObserver) + this.iframeObservers.set(window.document, iframeObserver) iframeObserver.syntheticObserve(rootNodeId, window.document) } disconnect() { this.iframeOffsets.clear() Element.prototype.attachShadow = attachShadowNativeFn - this.iframeObservers.forEach((o) => o.disconnect()) - this.iframeObservers = [] - this.shadowRootObservers.forEach((o) => o.disconnect()) - this.shadowRootObservers = [] + this.iframeObservers = new WeakMap() + this.shadowRootObservers = new WeakMap() super.disconnect() } }