utility.js revision 558790d6acca3451cf3a6b497803a5f07d0bec58
1// Copyright (c) 2013 The Chromium Authors. All rights reserved.
2// Use of this source code is governed by a BSD-style license that can be
3// found in the LICENSE file.
4
5'use strict';
6
7// TODO(vadimt): Remove alerts.
8
9/**
10 * @fileoverview Utility objects and functions for Google Now extension.
11 */
12
13// TODO(vadimt): Figure out the server name. Use it in the manifest and for
14// NOTIFICATION_CARDS_URL. Meanwhile, to use the feature, you need to manually
15// set the server name via local storage.
16/**
17 * Notification server URL.
18 */
19var NOTIFICATION_CARDS_URL = localStorage['server_url'];
20
21/**
22 * Checks for internal errors.
23 * @param {boolean} condition Condition that must be true.
24 * @param {string} message Diagnostic message for the case when the condition is
25 *     false.
26 */
27function verify(condition, message) {
28  if (!condition)
29    throw new Error('\nASSERT: ' + message);
30}
31
32/**
33 * Builds a request to the notification server.
34 * @param {string} handlerName Server handler to send the request to.
35 * @param {string} contentType Value for the Content-type header.
36 * @return {XMLHttpRequest} Server request.
37 */
38function buildServerRequest(handlerName, contentType) {
39  var request = new XMLHttpRequest();
40
41  request.responseType = 'text';
42  request.open('POST', NOTIFICATION_CARDS_URL + '/' + handlerName, true);
43  request.setRequestHeader('Content-type', contentType);
44
45  return request;
46}
47
48/**
49 * Builds the object to manage tasks (mutually exclusive chains of events).
50 * @param {function(string, string): boolean} areConflicting Function that
51 *     checks if a new task can't be added to a task queue that contains an
52 *     existing task.
53 * @return {Object} Task manager interface.
54 */
55function buildTaskManager(areConflicting) {
56  /**
57   * Queue of scheduled tasks. The first element, if present, corresponds to the
58   * currently running task.
59   * @type {Array.<Object.<string, function(function())>>}
60   */
61  var queue = [];
62
63  /**
64   * Count of unfinished callbacks of the current task.
65   * @type {number}
66   */
67  var taskPendingCallbackCount = 0;
68
69  /**
70   * Required callbacks that are not yet called. Includes both task and non-task
71   * callbacks. This is a map from unique callback id to the stack at the moment
72   * when the callback was wrapped. This stack identifies the callback.
73   * Used only for diagnostics.
74   * @type {Object.<number, string>}
75   */
76  var pendingCallbacks = {};
77
78  /**
79   * True if currently executed code is a part of a task.
80   * @type {boolean}
81   */
82  var isInTask = false;
83
84  /**
85   * Starts the first queued task.
86   */
87  function startFirst() {
88    verify(queue.length >= 1, 'startFirst: queue is empty');
89    verify(!isInTask, 'startFirst: already in task');
90    isInTask = true;
91
92    // Start the oldest queued task, but don't remove it from the queue.
93    verify(
94        taskPendingCallbackCount == 0,
95        'tasks.startFirst: still have pending task callbacks: ' +
96        taskPendingCallbackCount +
97        ', queue = ' + JSON.stringify(queue) +
98        ', pendingCallbacks = ' + JSON.stringify(pendingCallbacks));
99    var entry = queue[0];
100    console.log('Starting task ' + entry.name);
101
102    entry.task(function() {});  // TODO(vadimt): Don't pass parameter.
103
104    verify(isInTask, 'startFirst: not in task at exit');
105    isInTask = false;
106    if (taskPendingCallbackCount == 0)
107      finish();
108  }
109
110  /**
111   * Checks if a new task can be added to the task queue.
112   * @param {string} taskName Name of the new task.
113   * @return {boolean} Whether the new task can be added.
114   */
115  function canQueue(taskName) {
116    for (var i = 0; i < queue.length; ++i) {
117      if (areConflicting(taskName, queue[i].name)) {
118        console.log('Conflict: new=' + taskName +
119                    ', scheduled=' + queue[i].name);
120        return false;
121      }
122    }
123
124    return true;
125  }
126
127  /**
128   * Adds a new task. If another task is not running, runs the task immediately.
129   * If any task in the queue is not compatible with the task, ignores the new
130   * task. Otherwise, stores the task for future execution.
131   * @param {string} taskName Name of the task.
132   * @param {function(function())} task Function to run. Takes a callback
133   *     parameter.
134   */
135  function add(taskName, task) {
136    console.log('Adding task ' + taskName);
137    if (!canQueue(taskName))
138      return;
139
140    queue.push({name: taskName, task: task});
141
142    if (queue.length == 1) {
143      startFirst();
144    }
145  }
146
147  /**
148   * Completes the current task and starts the next queued task if available.
149   */
150  function finish() {
151    verify(queue.length >= 1,
152           'tasks.finish: The task queue is empty');
153    console.log('Finishing task ' + queue[0].name);
154    queue.shift();
155
156    if (queue.length >= 1)
157      startFirst();
158  }
159
160  // Limiting 1 error report per background page load.
161  var errorReported = false;
162
163  /**
164   * Sends an error report to the server.
165   * @param {Error} error Error to report.
166   */
167  function sendErrorReport(error) {
168    var filteredStack = error.stack.replace(/.*\n/, '\n');
169    var file;
170    var line;
171    var topFrameMatches = filteredStack.match(/\(.*\)/);
172    // topFrameMatches's example:
173    // (chrome-extension://pmofbkohncoogjjhahejjfbppikbjigm/utility.js:308:19)
174    var crashLocation = topFrameMatches && topFrameMatches[0];
175    if (crashLocation) {
176      var topFrameElements =
177          crashLocation.substring(1, crashLocation.length - 1).split(':');
178      // topFrameElements for the above example will look like:
179      // [0] chrome-extension
180      // [1] //pmofbkohncoogjjhahejjfbppikbjigm/utility.js
181      // [2] 308
182      // [3] 19
183      if (topFrameElements.length >= 3) {
184        file = topFrameElements[0] + ':' + topFrameElements[1];
185        line = topFrameElements[2];
186      }
187    }
188    var requestParameters =
189        'error=' + encodeURIComponent(error.name) +
190        '&script=' + encodeURIComponent(file) +
191        '&line=' + encodeURIComponent(line) +
192        '&trace=' + encodeURIComponent(filteredStack);
193    var request = buildServerRequest('jserror',
194                                     'application/x-www-form-urlencoded');
195    request.onloadend = function(event) {
196      console.log('sendErrorReport status: ' + request.status);
197    };
198    request.send(requestParameters);
199  }
200
201  /**
202   * Unique ID of the next callback.
203   * @type {number}
204   */
205  var nextCallbackId = 0;
206
207  /**
208   * Adds error processing to an API callback.
209   * @param {Function} callback Callback to instrument.
210   * @param {boolean=} opt_dontRequire True if the callback is not required to
211   *     be invoked.
212   * @return {Function} Instrumented callback.
213   */
214  function wrapCallback(callback, opt_dontRequire) {
215    verify(!(opt_dontRequire && isInTask), 'Unrequired callback in a task.');
216    var callbackId = nextCallbackId++;
217    var isTaskCallback = isInTask;
218    if (isTaskCallback)
219      ++taskPendingCallbackCount;
220    if (!opt_dontRequire)
221      pendingCallbacks[callbackId] = new Error().stack;
222
223    return function() {
224      // This is the wrapper for the callback.
225      try {
226        if (isTaskCallback) {
227          verify(!isInTask, 'wrapCallback: already in task');
228          isInTask = true;
229        }
230        if (!opt_dontRequire)
231          delete pendingCallbacks[callbackId];
232
233        // Call the original callback.
234        callback.apply(null, arguments);
235
236        if (isTaskCallback) {
237          verify(isInTask, 'wrapCallback: not in task at exit');
238          isInTask = false;
239          if (--taskPendingCallbackCount == 0)
240            finish();
241        }
242      } catch (error) {
243        var message = 'Uncaught exception:\n' + error.stack;
244        console.error(message);
245        if (!errorReported) {
246          errorReported = true;
247          chrome.metricsPrivate.getIsCrashReportingEnabled(function(isEnabled) {
248            if (isEnabled)
249              sendErrorReport(error);
250          });
251          alert(message);
252        }
253      }
254    };
255  }
256
257  /**
258   * Instruments an API function to add error processing to its user
259   * code-provided callback.
260   * @param {Object} namespace Namespace of the API function.
261   * @param {string} functionName Name of the API function.
262   * @param {number} callbackParameter Index of the callback parameter to this
263   *     API function.
264   */
265  function instrumentApiFunction(namespace, functionName, callbackParameter) {
266    var originalFunction = namespace[functionName];
267
268    if (!originalFunction)
269      alert('Cannot instrument ' + functionName);
270
271    namespace[functionName] = function() {
272      // This is the wrapper for the API function. Pass the wrapped callback to
273      // the original function.
274      var callback = arguments[callbackParameter];
275      if (typeof callback != 'function') {
276        alert('Argument ' + callbackParameter + ' of ' + functionName +
277              ' is not a function');
278      }
279      arguments[callbackParameter] = wrapCallback(
280          callback, functionName == 'addListener');
281      return originalFunction.apply(namespace, arguments);
282    };
283  }
284
285  instrumentApiFunction(chrome.alarms.onAlarm, 'addListener', 0);
286  instrumentApiFunction(chrome.runtime.onSuspend, 'addListener', 0);
287
288  chrome.runtime.onSuspend.addListener(function() {
289    var stringifiedPendingCallbacks = JSON.stringify(pendingCallbacks);
290    verify(
291        queue.length == 0 && stringifiedPendingCallbacks == '{}',
292        'Incomplete task or pending callbacks when unloading event page,' +
293        ' queue = ' + JSON.stringify(queue) +
294        ', pendingCallbacks = ' + stringifiedPendingCallbacks);
295  });
296
297  return {
298    add: add,
299    debugSetStepName: function() {},  // TODO(vadimt): remove
300    instrumentApiFunction: instrumentApiFunction,
301    wrapCallback: wrapCallback
302  };
303}
304
305var storage = chrome.storage.local;
306
307/**
308 * Builds an object to manage retrying activities with exponential backoff.
309 * @param {string} name Name of this attempt manager.
310 * @param {function()} attempt Activity that the manager retries until it
311 *     calls 'stop' method.
312 * @param {number} initialDelaySeconds Default first delay until first retry.
313 * @param {number} maximumDelaySeconds Maximum delay between retries.
314 * @return {Object} Attempt manager interface.
315 */
316function buildAttemptManager(
317    name, attempt, initialDelaySeconds, maximumDelaySeconds) {
318  var alarmName = 'attempt-scheduler-' + name;
319  var currentDelayStorageKey = 'current-delay-' + name;
320
321  /**
322   * Creates an alarm for the next attempt. The alarm is repeating for the case
323   * when the next attempt crashes before registering next alarm.
324   * @param {number} delaySeconds Delay until next retry.
325   */
326  function createAlarm(delaySeconds) {
327    var alarmInfo = {
328      delayInMinutes: delaySeconds / 60,
329      periodInMinutes: maximumDelaySeconds / 60
330    };
331    chrome.alarms.create(alarmName, alarmInfo);
332  }
333
334  /**
335   * Schedules next attempt.
336   * @param {number=} opt_previousDelaySeconds Previous delay in a sequence of
337   *     retry attempts, if specified. Not specified for scheduling first retry
338   *     in the exponential sequence.
339   */
340  function scheduleNextAttempt(opt_previousDelaySeconds) {
341    var base = opt_previousDelaySeconds ? opt_previousDelaySeconds * 2 :
342                                          initialDelaySeconds;
343    var newRetryDelaySeconds =
344        Math.min(base * (1 + 0.2 * Math.random()), maximumDelaySeconds);
345
346    createAlarm(newRetryDelaySeconds);
347
348    var items = {};
349    items[currentDelayStorageKey] = newRetryDelaySeconds;
350    storage.set(items);
351  }
352
353  /**
354   * Starts repeated attempts.
355   * @param {number=} opt_firstDelaySeconds Time until the first attempt, if
356   *     specified. Otherwise, initialDelaySeconds will be used for the first
357   *     attempt.
358   */
359  function start(opt_firstDelaySeconds) {
360    if (opt_firstDelaySeconds) {
361      createAlarm(opt_firstDelaySeconds);
362      storage.remove(currentDelayStorageKey);
363    } else {
364      scheduleNextAttempt();
365    }
366  }
367
368  /**
369   * Stops repeated attempts.
370   */
371  function stop() {
372    chrome.alarms.clear(alarmName);
373    storage.remove(currentDelayStorageKey);
374  }
375
376  /**
377   * Plans for the next attempt.
378   * @param {function()} callback Completion callback. It will be invoked after
379   *     the planning is done.
380   */
381  function planForNext(callback) {
382    storage.get(currentDelayStorageKey, function(items) {
383      console.log('planForNext-get-storage ' + JSON.stringify(items));
384      scheduleNextAttempt(items[currentDelayStorageKey]);
385      callback();
386    });
387  }
388
389  chrome.alarms.onAlarm.addListener(function(alarm) {
390    if (alarm.name == alarmName)
391      attempt();
392  });
393
394  return {
395    start: start,
396    planForNext: planForNext,
397    stop: stop
398  };
399}
400