/* 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 {classes: Cc, interfaces: Ci, utils: Cu, results: Cr} = Components; Cu.import("resource://gre/modules/XPCOMUtils.jsm"); Cu.import("resource://gre/modules/Services.jsm"); Cu.import('resource://gre/modules/Task.jsm'); XPCOMUtils.defineLazyModuleGetter(this, 'LogManager', 'resource://services-common/logmanager.js'); XPCOMUtils.defineLazyModuleGetter(this, 'Log', 'resource://gre/modules/Log.jsm'); XPCOMUtils.defineLazyModuleGetter(this, 'Preferences', 'resource://gre/modules/Preferences.jsm'); XPCOMUtils.defineLazyModuleGetter(this, 'setTimeout', 'resource://gre/modules/Timer.jsm'); XPCOMUtils.defineLazyModuleGetter(this, 'clearTimeout', 'resource://gre/modules/Timer.jsm'); // The main readinglist module. XPCOMUtils.defineLazyModuleGetter(this, 'ReadingList', 'resource:///modules/readinglist/ReadingList.jsm'); // The "engine" XPCOMUtils.defineLazyModuleGetter(this, 'Sync', 'resource:///modules/readinglist/Sync.jsm'); this.EXPORTED_SYMBOLS = ["ReadingListScheduler"]; // A list of "external" observer topics that may cause us to change when we // sync. const OBSERVERS = [ // We don't sync when offline and restart when online. "network:offline-status-changed", // FxA notifications also cause us to check if we should sync. "fxaccounts:onverified", // some notifications the engine might send if we have been requested to backoff. "readinglist:backoff-requested", // request to sync now "readinglist:user-sync", ]; let prefs = new Preferences("readinglist.scheduler."); // A helper to manage our interval values. let intervals = { // Getters for our intervals. _fixupIntervalPref(prefName, def) { // All pref values are seconds, but we return ms. return prefs.get(prefName, def) * 1000; }, // How long after startup do we do an initial sync? get initial() this._fixupIntervalPref("initial", 10), // 10 seconds. // Every interval after the first. get schedule() this._fixupIntervalPref("schedule", 2 * 60 * 60), // 2 hours // After an error get retry() this._fixupIntervalPref("retry", 2 * 60), // 2 mins }; // This is the implementation, but it's not exposed directly. function InternalScheduler(readingList = null) { // oh, I don't know what logs yet - let's guess! let logs = [ "browserwindow.syncui", "FirefoxAccounts", "readinglist.api", "readinglist.serverclient", "readinglist.sync", ]; this._logManager = new LogManager("readinglist.", logs, "readinglist"); this.log = Log.repository.getLogger("readinglist.scheduler"); this.log.info("readinglist scheduler created.") this.state = this.STATE_OK; this.readingList = readingList || ReadingList; // hook point for tests. // don't this.init() here, but instead at the module level - tests want to // add hooks before it is called. } InternalScheduler.prototype = { // When the next scheduled sync should happen. If we can sync, there will // be a timer set to fire then. If we can't sync there will not be a timer, // but it will be set to fire then as soon as we can. _nextScheduledSync: null, // The time when the most-recent "backoff request" expires - we will never // schedule a new timer before this. _backoffUntil: 0, // Our current timer. _timer: null, // Our timer fires a promise - _timerRunning is true until it resolves or // rejects. _timerRunning: false, // Our sync engine - XXX - maybe just a callback? _engine: Sync, // Our state variable and constants. state: null, STATE_OK: "ok", STATE_ERROR_AUTHENTICATION: "authentication error", STATE_ERROR_OTHER: "other error", init() { this.log.info("scheduler initialzing"); this._setupRLListener(); this._observe = this.observe.bind(this); for (let notification of OBSERVERS) { Services.obs.addObserver(this._observe, notification, false); } this._nextScheduledSync = Date.now() + intervals.initial; this._setupTimer(); }, _setupRLListener() { let maybeSync = () => { if (this._timerRunning) { // If a sync is currently running it is possible it will miss the change // just made, so tell the timer the next sync should be 1 ms after // it completes (we don't use zero as that has special meaning re backoffs) this._maybeReschedule(1); } else { // Do the sync now. this._syncNow(); } }; let listener = { onItemAdded: maybeSync, onItemUpdated: maybeSync, onItemDeleted: maybeSync, } this.readingList.addListener(listener); }, // Note: only called by tests. finalize() { this.log.info("scheduler finalizing"); this._clearTimer(); for (let notification of OBSERVERS) { Services.obs.removeObserver(this._observe, notification); } this._observe = null; }, observe(subject, topic, data) { this.log.debug("observed ${}", topic); switch (topic) { case "readinglist:backoff-requested": { // The subject comes in as a string, a number of seconds. let interval = parseInt(data, 10); if (isNaN(interval)) { this.log.warn("Backoff request had non-numeric value", data); return; } this.log.info("Received a request to backoff for ${} seconds", interval); this._backoffUntil = Date.now() + interval * 1000; this._maybeReschedule(0); break; } case "readinglist:user-sync": this._syncNow(); break; case "fxaccounts:onverified": // If we were in an authentication error state, reset that now. if (this.state == this.STATE_ERROR_AUTHENTICATION) { this.state = this.STATE_OK; } break; // The rest just indicate that now is probably a good time to check if // we can sync as normal using whatever schedule was previously set. default: break; } // When observers fire we ignore the current sync error state as the // notification may indicate it's been resolved. this._setupTimer(true); }, // Is the current error state such that we shouldn't schedule a new sync. _isBlockedOnError() { // this needs more thought... return this.state == this.STATE_ERROR_AUTHENTICATION; }, // canSync indicates if we can currently sync. _canSync(ignoreBlockingErrors = false) { if (!prefs.get("enabled")) { this.log.info("canSync=false - syncing is disabled"); return false; } if (Services.io.offline) { this.log.info("canSync=false - we are offline"); return false; } if (!ignoreBlockingErrors && this._isBlockedOnError()) { this.log.info("canSync=false - we are in a blocked error state", this.state); return false; } this.log.info("canSync=true"); return true; }, // _setupTimer checks the current state and the environment to see when // we should next sync and creates the timer with the appropriate delay. _setupTimer(ignoreBlockingErrors = false) { if (!this._canSync(ignoreBlockingErrors)) { this._clearTimer(); return; } if (this._timer) { let when = new Date(this._nextScheduledSync); let delay = this._nextScheduledSync - Date.now(); this.log.info("checkStatus - already have a timer - will fire in ${delay}ms at ${when}", {delay, when}); return; } if (this._timerRunning) { this.log.info("checkStatus - currently syncing"); return; } // no timer and we can sync, so start a new one. let now = Date.now(); let delay = Math.max(0, this._nextScheduledSync - now); let when = new Date(now + delay); this.log.info("next scheduled sync is in ${delay}ms (at ${when})", {delay, when}) this._timer = this._setTimeout(delay); }, // Something (possibly naively) thinks the next sync should happen in // delay-ms. If there's a backoff in progress, ignore the requested delay // and use the back-off. If there's already a timer scheduled for earlier // than delay, let the earlier timer remain. Otherwise, use the requested // delay. _maybeReschedule(delay) { // If there's no delay specified and there's nothing currently scheduled, // it means a backoff request while the sync is actually running - there's // no need to do anything here - the next reschedule after the sync // completes will take the backoff into account. if (!delay && !this._nextScheduledSync) { this.log.debug("_maybeReschedule ignoring a backoff request while running"); return; } let now = Date.now(); if (!this._nextScheduledSync) { this._nextScheduledSync = now + delay; } // If there is something currently scheduled before the requested delay, // keep the existing value (eg, if we have a timer firing in 1 second, and // get a notification that says we should sync in 2 seconds, we keep the 1 // second value) this._nextScheduledSync = Math.min(this._nextScheduledSync, now + delay); // But we still need to honor a backoff. this._nextScheduledSync = Math.max(this._nextScheduledSync, this._backoffUntil); // And always create a new timer next time _setupTimer is called. this._clearTimer(); }, // callback for when the timer fires. _doSync() { this.log.debug("starting sync"); this._timer = null; this._timerRunning = true; // flag that there's no new schedule yet, so a request coming in while // we are running does the right thing. this._nextScheduledSync = 0; Services.obs.notifyObservers(null, "readinglist:sync:start", null); this._engine.start().then(() => { this.log.info("Sync completed successfully"); // Write a pref in the same format used to services/sync to indicate // the last success. prefs.set("lastSync", new Date().toString()); this.state = this.STATE_OK; this._logManager.resetFileLog(this._logManager.REASON_SUCCESS); Services.obs.notifyObservers(null, "readinglist:sync:finish", null); return intervals.schedule; }).catch(err => { this.log.error("Sync failed", err); // XXX - how to detect an auth error? this.state = err == this._engine.ERROR_AUTHENTICATION ? this.STATE_ERROR_AUTHENTICATION : this.STATE_ERROR_OTHER; this._logManager.resetFileLog(this._logManager.REASON_ERROR); Services.obs.notifyObservers(null, "readinglist:sync:error", null); return intervals.retry; }).then(nextDelay => { this._timerRunning = false; // ensure a new timer is setup for the appropriate next time. this._maybeReschedule(nextDelay); this._setupTimer(); this._onAutoReschedule(); // just for tests... }).catch(err => { // We should never get here, but better safe than sorry... this.log.error("Failed to reschedule after sync completed", err); }); }, _clearTimer() { if (this._timer) { clearTimeout(this._timer); this._timer = null; } }, // A function to "sync now", but not allowing it to start if one is // already running, and rescheduling the timer. // To call this, just send a "readinglist:user-sync" notification. _syncNow() { if (!prefs.get("enabled")) { this.log.info("syncNow() but syncing is disabled - ignoring"); return; } if (this._timerRunning) { this.log.info("syncNow() but a sync is already in progress - ignoring"); return; } this._clearTimer(); this._doSync(); }, // A couple of hook-points for testing. // xpcshell tests hook this so (a) it can check the expected delay is set // and (b) to ignore the delay and set a timeout of 0 so the test is fast. _setTimeout(delay) { return setTimeout(() => this._doSync(), delay); }, // xpcshell tests hook this to make sure that the correct state etc exist // after a sync has been completed and a new timer created (or not). _onAutoReschedule() {}, }; let internalScheduler = new InternalScheduler(); internalScheduler.init(); // The public interface into this module is tiny, so a simple object that // delegates to the implementation. let ReadingListScheduler = { get STATE_OK() internalScheduler.STATE_OK, get STATE_ERROR_AUTHENTICATION() internalScheduler.STATE_ERROR_AUTHENTICATION, get STATE_ERROR_OTHER() internalScheduler.STATE_ERROR_OTHER, get state() internalScheduler.state, }; // These functions are exposed purely for tests, which manage to grab them // via a BackstagePass. function createTestableScheduler(readingList) { // kill the "real" scheduler as we don't want it listening to notifications etc. if (internalScheduler) { internalScheduler.finalize(); internalScheduler = null; } // No .init() call - that's up to the tests after hooking. return new InternalScheduler(readingList); } // mochitests want the internal state of the real scheduler for various things. function getInternalScheduler() { return internalScheduler; }