Andrey Lushnikov 2b495c9750
browser(firefox): fix SimpleChannel to await initialization (#4311)
As Joel noticed recently, MessageManager in firefox doesn't guarantee
message delivery if the opposite end hasn't been initialized yet. In
this case, message will be silently dropped on the ground.

To fix this, we establish a handshake in SimpleChannel to make sure that
both ends are initialized, end buffer outgoing messages until this
happens.

Drive-by: serialize dialog events to only deliver *after* the
`Page.ready` protocol event. Otherwise, we deliver dialog events to the
unreported page.
2020-11-02 16:21:34 -08:00

579 lines
19 KiB
JavaScript

/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
"use strict";
const Ci = Components.interfaces;
const Cr = Components.results;
const Cu = Components.utils;
const {Helper} = ChromeUtils.import('chrome://juggler/content/Helper.js');
const {SimpleChannel} = ChromeUtils.import('chrome://juggler/content/SimpleChannel.js');
const {EventEmitter} = ChromeUtils.import('resource://gre/modules/EventEmitter.jsm');
const {Runtime} = ChromeUtils.import('chrome://juggler/content/content/Runtime.js');
const helper = new Helper();
class FrameTree {
constructor(rootDocShell) {
EventEmitter.decorate(this);
this._browsingContextGroup = rootDocShell.browsingContext.group;
if (!this._browsingContextGroup.__jugglerFrameTrees)
this._browsingContextGroup.__jugglerFrameTrees = new Set();
this._browsingContextGroup.__jugglerFrameTrees.add(this);
this._scriptsToEvaluateOnNewDocument = new Map();
this._webSocketEventService = Cc[
"@mozilla.org/websocketevent/service;1"
].getService(Ci.nsIWebSocketEventService);
this._bindings = new Map();
this._runtime = new Runtime(false /* isWorker */);
this._workers = new Map();
this._docShellToFrame = new Map();
this._frameIdToFrame = new Map();
this._pageReady = false;
this._mainFrame = this._createFrame(rootDocShell);
const webProgress = rootDocShell.QueryInterface(Ci.nsIInterfaceRequestor)
.getInterface(Ci.nsIWebProgress);
this.QueryInterface = ChromeUtils.generateQI([
Ci.nsIWebProgressListener,
Ci.nsIWebProgressListener2,
Ci.nsISupportsWeakReference,
]);
this._wdm = Cc["@mozilla.org/dom/workers/workerdebuggermanager;1"].createInstance(Ci.nsIWorkerDebuggerManager);
this._wdmListener = {
QueryInterface: ChromeUtils.generateQI([Ci.nsIWorkerDebuggerManagerListener]),
onRegister: this._onWorkerCreated.bind(this),
onUnregister: this._onWorkerDestroyed.bind(this),
};
this._wdm.addListener(this._wdmListener);
for (const workerDebugger of this._wdm.getWorkerDebuggerEnumerator())
this._onWorkerCreated(workerDebugger);
const flags = Ci.nsIWebProgress.NOTIFY_STATE_DOCUMENT |
Ci.nsIWebProgress.NOTIFY_FRAME_LOCATION;
this._eventListeners = [
helper.addObserver(this._onDOMWindowCreated.bind(this), 'content-document-global-created'),
helper.addObserver(this._onDOMWindowCreated.bind(this), 'juggler-dom-window-reused'),
helper.addObserver(subject => this._onDocShellCreated(subject.QueryInterface(Ci.nsIDocShell)), 'webnavigation-create'),
helper.addObserver(subject => this._onDocShellDestroyed(subject.QueryInterface(Ci.nsIDocShell)), 'webnavigation-destroy'),
helper.addProgressListener(webProgress, this, flags),
];
}
workers() {
return [...this._workers.values()];
}
runtime() {
return this._runtime;
}
_frameForWorker(workerDebugger) {
if (workerDebugger.type !== Ci.nsIWorkerDebugger.TYPE_DEDICATED)
return null;
if (!workerDebugger.window)
return null;
const docShell = workerDebugger.window.docShell;
return this._docShellToFrame.get(docShell) || null;
}
_onDOMWindowCreated(window) {
const frame = this._docShellToFrame.get(window.docShell) || null;
if (!frame)
return;
frame._onGlobalObjectCleared();
this.emit(FrameTree.Events.GlobalObjectCreated, { frame, window });
}
_onWorkerCreated(workerDebugger) {
// Note: we do not interoperate with firefox devtools.
if (workerDebugger.isInitialized)
return;
const frame = this._frameForWorker(workerDebugger);
if (!frame)
return;
const worker = new Worker(frame, workerDebugger);
this._workers.set(workerDebugger, worker);
this.emit(FrameTree.Events.WorkerCreated, worker);
}
_onWorkerDestroyed(workerDebugger) {
const worker = this._workers.get(workerDebugger);
if (!worker)
return;
worker.dispose();
this._workers.delete(workerDebugger);
this.emit(FrameTree.Events.WorkerDestroyed, worker);
}
allFramesInBrowsingContextGroup(group) {
const frames = [];
for (const frameTree of (group.__jugglerFrameTrees || []))
frames.push(...frameTree.frames());
return frames;
}
isPageReady() {
return this._pageReady;
}
forcePageReady() {
if (this._pageReady)
return false;
this._pageReady = true;
this.emit(FrameTree.Events.PageReady);
return true;
}
addScriptToEvaluateOnNewDocument(script) {
const scriptId = helper.generateId();
this._scriptsToEvaluateOnNewDocument.set(scriptId, script);
return scriptId;
}
removeScriptToEvaluateOnNewDocument(scriptId) {
this._scriptsToEvaluateOnNewDocument.delete(scriptId);
}
addBinding(name, script) {
this._bindings.set(name, script);
for (const frame of this.frames())
frame._addBinding(name, script);
}
setColorScheme(colorScheme) {
const docShell = this._mainFrame._docShell;
switch (colorScheme) {
case 'light': docShell.colorSchemeOverride = Ci.nsIDocShell.COLOR_SCHEME_OVERRIDE_LIGHT; break;
case 'dark': docShell.colorSchemeOverride = Ci.nsIDocShell.COLOR_SCHEME_OVERRIDE_DARK; break;
case 'no-preference': docShell.colorSchemeOverride = Ci.nsIDocShell.COLOR_SCHEME_OVERRIDE_NO_PREFERENCE; break;
default: docShell.colorSchemeOverride = Ci.nsIDocShell.COLOR_SCHEME_OVERRIDE_NONE; break;
}
}
frameForDocShell(docShell) {
return this._docShellToFrame.get(docShell) || null;
}
frame(frameId) {
return this._frameIdToFrame.get(frameId) || null;
}
frames() {
let result = [];
collect(this._mainFrame);
return result;
function collect(frame) {
result.push(frame);
for (const subframe of frame._children)
collect(subframe);
}
}
mainFrame() {
return this._mainFrame;
}
dispose() {
this._browsingContextGroup.__jugglerFrameTrees.delete(this);
this._wdm.removeListener(this._wdmListener);
this._runtime.dispose();
helper.removeListeners(this._eventListeners);
}
onStateChange(progress, request, flag, status) {
if (!(request instanceof Ci.nsIChannel))
return;
const channel = request.QueryInterface(Ci.nsIChannel);
const docShell = progress.DOMWindow.docShell;
const frame = this._docShellToFrame.get(docShell);
if (!frame) {
dump(`ERROR: got a state changed event for un-tracked docshell!\n`);
return;
}
const isStart = flag & Ci.nsIWebProgressListener.STATE_START;
const isTransferring = flag & Ci.nsIWebProgressListener.STATE_TRANSFERRING;
const isStop = flag & Ci.nsIWebProgressListener.STATE_STOP;
const isDocument = flag & Ci.nsIWebProgressListener.STATE_IS_DOCUMENT;
if (isStart) {
// Starting a new navigation.
frame._pendingNavigationId = channelId(channel);
frame._pendingNavigationURL = channel.URI.spec;
this.emit(FrameTree.Events.NavigationStarted, frame);
} else if (isTransferring || (isStop && frame._pendingNavigationId && !status)) {
// Navigation is committed.
for (const subframe of frame._children)
this._detachFrame(subframe);
const navigationId = frame._pendingNavigationId;
frame._pendingNavigationId = null;
frame._pendingNavigationURL = null;
frame._lastCommittedNavigationId = navigationId;
frame._url = channel.URI.spec;
this.emit(FrameTree.Events.NavigationCommitted, frame);
if (frame === this._mainFrame)
this.forcePageReady();
} else if (isStop && frame._pendingNavigationId && status) {
// Navigation is aborted.
const navigationId = frame._pendingNavigationId;
frame._pendingNavigationId = null;
frame._pendingNavigationURL = null;
// Always report download navigation as failure to match other browsers.
const errorText = helper.getNetworkErrorStatusText(status);
this.emit(FrameTree.Events.NavigationAborted, frame, navigationId, errorText);
if (frame === this._mainFrame && status !== Cr.NS_BINDING_ABORTED)
this.forcePageReady();
}
if (isStop && isDocument)
this.emit(FrameTree.Events.Load, frame);
}
onFrameLocationChange(progress, request, location, flags) {
const docShell = progress.DOMWindow.docShell;
const frame = this._docShellToFrame.get(docShell);
const sameDocumentNavigation = !!(flags & Ci.nsIWebProgressListener.LOCATION_CHANGE_SAME_DOCUMENT);
if (frame && sameDocumentNavigation) {
frame._url = location.spec;
this.emit(FrameTree.Events.SameDocumentNavigation, frame);
}
}
_onDocShellCreated(docShell) {
// Bug 1142752: sometimes, the docshell appears to be immediately
// destroyed, bailout early to prevent random exceptions.
if (docShell.isBeingDestroyed())
return;
// If this docShell doesn't belong to our frame tree - do nothing.
let root = docShell;
while (root.parent)
root = root.parent;
if (root === this._mainFrame._docShell)
this._createFrame(docShell);
}
_createFrame(docShell) {
const parentFrame = this._docShellToFrame.get(docShell.parent) || null;
const frame = new Frame(this, this._runtime, docShell, parentFrame);
this._docShellToFrame.set(docShell, frame);
this._frameIdToFrame.set(frame.id(), frame);
this.emit(FrameTree.Events.FrameAttached, frame);
// Create execution context **after** reporting frame.
// This is our protocol contract.
if (frame.domWindow())
frame._onGlobalObjectCleared();
return frame;
}
_onDocShellDestroyed(docShell) {
const frame = this._docShellToFrame.get(docShell);
if (frame)
this._detachFrame(frame);
}
_detachFrame(frame) {
// Detach all children first
for (const subframe of frame._children)
this._detachFrame(subframe);
this._docShellToFrame.delete(frame._docShell);
this._frameIdToFrame.delete(frame.id());
if (frame._parentFrame)
frame._parentFrame._children.delete(frame);
frame._parentFrame = null;
frame.dispose();
this.emit(FrameTree.Events.FrameDetached, frame);
}
}
FrameTree.Events = {
BindingCalled: 'bindingcalled',
FrameAttached: 'frameattached',
FrameDetached: 'framedetached',
GlobalObjectCreated: 'globalobjectcreated',
WorkerCreated: 'workercreated',
WorkerDestroyed: 'workerdestroyed',
WebSocketCreated: 'websocketcreated',
WebSocketOpened: 'websocketopened',
WebSocketClosed: 'websocketclosed',
WebSocketFrameReceived: 'websocketframereceived',
WebSocketFrameSent: 'websocketframesent',
NavigationStarted: 'navigationstarted',
NavigationCommitted: 'navigationcommitted',
NavigationAborted: 'navigationaborted',
SameDocumentNavigation: 'samedocumentnavigation',
PageReady: 'pageready',
Load: 'load',
};
class Frame {
constructor(frameTree, runtime, docShell, parentFrame) {
this._frameTree = frameTree;
this._runtime = runtime;
this._docShell = docShell;
this._children = new Set();
this._frameId = helper.browsingContextToFrameId(this._docShell.browsingContext);
this._parentFrame = null;
this._url = '';
if (docShell.domWindow && docShell.domWindow.location)
this._url = docShell.domWindow.location.href;
if (parentFrame) {
this._parentFrame = parentFrame;
parentFrame._children.add(this);
}
this._lastCommittedNavigationId = null;
this._pendingNavigationId = null;
this._pendingNavigationURL = null;
this._textInputProcessor = null;
this._executionContext = null;
this._webSocketListenerInnerWindowId = 0;
// WebSocketListener calls frameReceived event before webSocketOpened.
// To avoid this, serialize event reporting.
this._webSocketInfos = new Map();
const dispatchWebSocketFrameReceived = (webSocketSerialID, frame) => this._frameTree.emit(FrameTree.Events.WebSocketFrameReceived, {
frameId: this._frameId,
wsid: webSocketSerialID + '',
opcode: frame.opCode,
data: frame.opCode !== 1 ? btoa(frame.payload) : frame.payload,
});
this._webSocketListener = {
QueryInterface: ChromeUtils.generateQI([Ci.nsIWebSocketEventListener, ]),
webSocketCreated: (webSocketSerialID, uri, protocols) => {
this._frameTree.emit(FrameTree.Events.WebSocketCreated, {
frameId: this._frameId,
wsid: webSocketSerialID + '',
requestURL: uri,
});
this._webSocketInfos.set(webSocketSerialID, {
opened: false,
pendingIncomingFrames: [],
});
},
webSocketOpened: (webSocketSerialID, effectiveURI, protocols, extensions, httpChannelId) => {
this._frameTree.emit(FrameTree.Events.WebSocketOpened, {
frameId: this._frameId,
requestId: httpChannelId + '',
wsid: webSocketSerialID + '',
effectiveURL: effectiveURI,
});
const info = this._webSocketInfos.get(webSocketSerialID);
info.opened = true;
for (const frame of info.pendingIncomingFrames)
dispatchWebSocketFrameReceived(webSocketSerialID, frame);
},
webSocketMessageAvailable: (webSocketSerialID, data, messageType) => {
// We don't use this event.
},
webSocketClosed: (webSocketSerialID, wasClean, code, reason) => {
this._webSocketInfos.delete(webSocketSerialID);
let error = '';
if (!wasClean) {
const keys = Object.keys(Ci.nsIWebSocketChannel);
for (const key of keys) {
if (Ci.nsIWebSocketChannel[key] === code)
error = key;
}
}
this._frameTree.emit(FrameTree.Events.WebSocketClosed, {
frameId: this._frameId,
wsid: webSocketSerialID + '',
error,
});
},
frameReceived: (webSocketSerialID, frame) => {
// Report only text and binary frames.
if (frame.opCode !== 1 && frame.opCode !== 2)
return;
const info = this._webSocketInfos.get(webSocketSerialID);
if (info.opened)
dispatchWebSocketFrameReceived(webSocketSerialID, frame);
else
info.pendingIncomingFrames.push(frame);
},
frameSent: (webSocketSerialID, frame) => {
// Report only text and binary frames.
if (frame.opCode !== 1 && frame.opCode !== 2)
return;
this._frameTree.emit(FrameTree.Events.WebSocketFrameSent, {
frameId: this._frameId,
wsid: webSocketSerialID + '',
opcode: frame.opCode,
data: frame.opCode !== 1 ? btoa(frame.payload) : frame.payload,
});
},
};
}
dispose() {
if (this._executionContext)
this._runtime.destroyExecutionContext(this._executionContext);
this._executionContext = null;
}
_addBinding(name, script) {
Cu.exportFunction((...args) => {
this._frameTree.emit(FrameTree.Events.BindingCalled, {
frame: this,
name,
payload: args[0]
});
}, this.domWindow(), {
defineAs: name,
});
this.domWindow().eval(script);
}
_onGlobalObjectCleared() {
const webSocketService = this._frameTree._webSocketEventService;
if (this._webSocketListenerInnerWindowId)
webSocketService.removeListener(this._webSocketListenerInnerWindowId, this._webSocketListener);
this._webSocketListenerInnerWindowId = this.domWindow().windowGlobalChild.innerWindowId;
webSocketService.addListener(this._webSocketListenerInnerWindowId, this._webSocketListener);
if (this._executionContext)
this._runtime.destroyExecutionContext(this._executionContext);
this._executionContext = this._runtime.createExecutionContext(this.domWindow(), this.domWindow(), {
frameId: this._frameId,
name: '',
});
for (const [name, script] of this._frameTree._bindings)
this._addBinding(name, script);
for (const script of this._frameTree._scriptsToEvaluateOnNewDocument.values()) {
try {
const result = this._executionContext.evaluateScript(script);
if (result && result.objectId)
this._executionContext.disposeObject(result.objectId);
} catch (e) {
dump(`ERROR: ${e.message}\n${e.stack}\n`);
}
}
}
executionContext() {
return this._executionContext;
}
textInputProcessor() {
if (!this._textInputProcessor) {
this._textInputProcessor = Cc["@mozilla.org/text-input-processor;1"].createInstance(Ci.nsITextInputProcessor);
this._textInputProcessor.beginInputTransactionForTests(this._docShell.DOMWindow);
}
return this._textInputProcessor;
}
pendingNavigationId() {
return this._pendingNavigationId;
}
pendingNavigationURL() {
return this._pendingNavigationURL;
}
lastCommittedNavigationId() {
return this._lastCommittedNavigationId;
}
docShell() {
return this._docShell;
}
domWindow() {
return this._docShell.domWindow;
}
name() {
const frameElement = this._docShell.domWindow.frameElement;
let name = '';
if (frameElement)
name = frameElement.getAttribute('name') || frameElement.getAttribute('id') || '';
return name;
}
parentFrame() {
return this._parentFrame;
}
id() {
return this._frameId;
}
url() {
return this._url;
}
}
class Worker {
constructor(frame, workerDebugger) {
this._frame = frame;
this._workerId = helper.generateId();
this._workerDebugger = workerDebugger;
workerDebugger.initialize('chrome://juggler/content/content/WorkerMain.js');
this._channel = new SimpleChannel(`content::worker[${this._workerId}]`);
this._channel.setTransport({
sendMessage: obj => workerDebugger.postMessage(JSON.stringify(obj)),
dispose: () => {},
});
this._workerDebuggerListener = {
QueryInterface: ChromeUtils.generateQI([Ci.nsIWorkerDebuggerListener]),
onMessage: msg => void this._channel._onMessage(JSON.parse(msg)),
onClose: () => void this._channel.dispose(),
onError: (filename, lineno, message) => {
dump(`Error in worker: ${message} @${filename}:${lineno}\n`);
},
};
workerDebugger.addListener(this._workerDebuggerListener);
}
channel() {
return this._channel;
}
frame() {
return this._frame;
}
id() {
return this._workerId;
}
url() {
return this._workerDebugger.url;
}
dispose() {
this._channel.dispose();
this._workerDebugger.removeListener(this._workerDebuggerListener);
}
}
function channelId(channel) {
if (channel instanceof Ci.nsIIdentChannel) {
const identChannel = channel.QueryInterface(Ci.nsIIdentChannel);
return String(identChannel.channelId);
}
return helper.generateId();
}
var EXPORTED_SYMBOLS = ['FrameTree'];
this.FrameTree = FrameTree;