--- a/services/common/utils.js
+++ b/services/common/utils.js
@@ -393,19 +393,18 @@ this.CommonUtils = {
/**
* Write a JSON object to the named file using OS.File and promises.
*
* @param contents a JS object. Will be serialized.
* @param path the path of the file to write.
* @return a promise, as produced by OS.File.writeAtomic.
*/
writeJSON: function(contents, path) {
- let encoder = new TextEncoder();
- let array = encoder.encode(JSON.stringify(contents));
- return OS.File.writeAtomic(path, array, {tmpPath: path + ".tmp"});
+ let data = JSON.stringify(contents);
+ return OS.File.writeAtomic(path, data, {encoding: "utf-8", tmpPath: path + ".tmp"});
},
/**
* Ensure that the specified value is defined in integer milliseconds since
* UNIX epoch.
*
* This throws an error if the value is not an integer, is negative, or looks
--- a/services/datareporting/DataReportingService.js
+++ b/services/datareporting/DataReportingService.js
@@ -4,16 +4,19 @@
"use strict";
const {classes: Cc, interfaces: Ci, utils: Cu} = Components;
Cu.import("resource://gre/modules/Preferences.jsm");
Cu.import("resource://gre/modules/XPCOMUtils.jsm");
Cu.import("resource://services-common/utils.js");
+Cu.import("resource://gre/modules/Promise.jsm");
+Cu.import("resource://gre/modules/Task.jsm");
+Cu.import("resource://gre/modules/osfile.jsm");
const ROOT_BRANCH = "datareporting.";
const POLICY_BRANCH = ROOT_BRANCH + "policy.";
const SESSIONS_BRANCH = ROOT_BRANCH + "sessions.";
const HEALTHREPORT_BRANCH = ROOT_BRANCH + "healthreport.";
const HEALTHREPORT_LOGGING_BRANCH = HEALTHREPORT_BRANCH + "logging.";
const DEFAULT_LOAD_DELAY_MSEC = 10 * 1000;
@@ -56,16 +59,23 @@ const DEFAULT_LOAD_DELAY_FIRST_RUN_MSEC
*/
this.DataReportingService = function () {
this.wrappedJSObject = this;
this._quitting = false;
this._os = Cc["@mozilla.org/observer-service;1"]
.getService(Ci.nsIObserverService);
+
+ this._clientID = null;
+ this._loadClientIdTask = null;
+ this._saveClientIdTask = null;
+
+ this._stateDir = null;
+ this._stateFilePath = null;
}
DataReportingService.prototype = Object.freeze({
classID: Components.ID("{41f6ae36-a79f-4613-9ac3-915e70f83789}"),
QueryInterface: XPCOMUtils.generateQI([Ci.nsIObserver,
Ci.nsISupportsWeakReference]),
@@ -121,16 +131,19 @@ DataReportingService.prototype = Object.
this.sessionRecorder = new SessionRecorder(SESSIONS_BRANCH);
this.sessionRecorder.onStartup();
}
// We can't interact with prefs until after the profile is present.
let policyPrefs = new Preferences(POLICY_BRANCH);
this.policy = new DataReportingPolicy(policyPrefs, this._prefs, this);
+ this._stateDir = OS.Path.join(OS.Constants.Path.profileDir, "datareporting");
+ this._stateFilePath = OS.Path.join(this._stateDir, "state.json");
+
this._os.addObserver(this, "sessionstore-windows-restored", true);
} catch (ex) {
Cu.reportError("Exception when initializing data reporting service: " +
CommonUtils.exceptionStr(ex));
}
break;
case "sessionstore-windows-restored":
@@ -279,16 +292,119 @@ DataReportingService.prototype = Object.
this.sessionRecorder);
// Wait for initialization to finish so if a shutdown occurs before init
// has finished we don't adversely affect app startup on next run.
this._healthReporter.init().then(function onInit() {
this._prefs.set("service.firstRun", true);
}.bind(this));
},
+
+ _loadClientID: Task.async(function* () {
+ if (this._loadClientIdTask) {
+ return this._loadClientIdTask;
+ }
+
+ // Previously we had the stable client ID managed in FHR.
+ // As we want to start correlating FHR and telemetry data (and moving towards
+ // unifying the two), we moved the ID management to the datareporting
+ // service. Consequently, we try to import the FHR ID first, so we can keep
+ // using it.
+
+ // Try to load the client id from the DRS state file first.
+ try {
+ let state = yield CommonUtils.readJSON(this._stateFilePath);
+ if (state && 'clientID' in state && typeof(state.clientID) == 'string') {
+ this._clientID = state.clientID;
+ this._loadClientIdTask = null;
+ return this._clientID;
+ }
+ } catch (e) {
+ // fall through to next option
+ }
+
+ // If we dont have DRS state yet, try to import from the FHR state.
+ try {
+ let fhrStatePath = OS.Path.join(OS.Constants.Path.profileDir, "healthreport", "state.json");
+ let state = yield CommonUtils.readJSON(fhrStatePath);
+ if (state && 'clientID' in state && typeof(state.clientID) == 'string') {
+ this._clientID = state.clientID;
+ this._loadClientIdTask = null;
+ this._saveClientID();
+ return this._clientID;
+ }
+ } catch (e) {
+ // fall through to next option
+ }
+
+ // We dont have an id from FHR yet, generate a new ID.
+ this._clientID = CommonUtils.generateUUID();
+ this._loadClientIdTask = null;
+ this._saveClientIdTask = this._saveClientID();
+
+ // Wait on persisting the id. Otherwise failure to save the ID would result in
+ // the client creating and subsequently sending multiple IDs to the server.
+ // This would appear as multiple clients submitting similar data, which would
+ // result in orphaning.
+ yield this._saveClientIdTask;
+
+ return this._clientID;
+ }),
+
+ _saveClientID: Task.async(function* () {
+ let obj = { clientID: this._clientID };
+ yield OS.File.makeDir(this._stateDir);
+ yield CommonUtils.writeJSON(obj, this._stateFilePath);
+ this._saveClientIdTask = null;
+ }),
+
+ /**
+ * This returns a promise resolving to the the stable client ID we use for
+ * data reporting (FHR & Telemetry). Previously exising FHR client IDs are
+ * migrated to this.
+ *
+ * @return Promise<string> The stable client ID.
+ */
+ getClientID: function() {
+ if (this._loadClientIdTask) {
+ return this._loadClientIdTask;
+ }
+
+ if (!this._clientID) {
+ this._loadClientIdTask = this._loadClientID();
+ return this._loadClientIdTask;
+ }
+
+ return Promise.resolve(this._clientID);
+ },
+
+ /**
+ * Reset the stable client id.
+ *
+ * @return Promise<string> The new client ID.
+ */
+ resetClientID: Task.async(function* () {
+ yield this._loadClientIdTask;
+ yield this._saveClientIdTask;
+
+ this._clientID = CommonUtils.generateUUID();
+ this._saveClientIdTask = this._saveClientID();
+ yield this._saveClientIdTask;
+
+ return this._clientID;
+ }),
+
+ /*
+ * Simulate a restart of the service. This is for testing only.
+ */
+ _reset: Task.async(function* () {
+ yield this._loadClientIdTask;
+ yield this._saveClientIdTask;
+ this._clientID = null;
+ }),
});
this.NSGetFactory = XPCOMUtils.generateNSGetFactory([DataReportingService]);
#define MERGED_COMPARTMENT
#include ../common/observers.js
;
--- a/services/datareporting/policy.jsm
+++ b/services/datareporting/policy.jsm
@@ -812,17 +812,18 @@ this.DataReportingPolicy.prototype = Obj
this._handleSubmissionFailure();
}.bind(this);
let chained = deferred.promise.then(onSuccess, onError);
this._log.info("Requesting data submission. Will expire at " +
requestExpiresDate);
try {
- this._listener[handler](this._inProgressSubmissionRequest);
+ let promise = this._listener[handler](this._inProgressSubmissionRequest);
+ chained = chained.then(() => promise, null);
} catch (ex) {
this._log.warn("Exception when calling " + handler + ": " +
CommonUtils.exceptionStr(ex));
this._inProgressSubmissionRequest = null;
this._handleSubmissionFailure();
return;
}
new file mode 100644
--- /dev/null
+++ b/services/datareporting/tests/xpcshell/test_client_id.js
@@ -0,0 +1,86 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const {classes: Cc, interfaces: Ci, utils: Cu} = Components;
+
+Cu.import("resource://gre/modules/Task.jsm");
+Cu.import("resource://services-common/utils.js");
+Cu.import("resource://gre/modules/osfile.jsm");
+Cu.import("resource://gre/modules/XPCOMUtils.jsm");
+
+XPCOMUtils.defineLazyGetter(this, "gDatareportingService",
+ () => Cc["@mozilla.org/datareporting/service;1"]
+ .getService(Ci.nsISupports)
+ .wrappedJSObject);
+
+function run_test() {
+ do_get_profile();
+
+ // Send the needed startup notifications to the datareporting service
+ // to ensure that it has been initialized.
+ gDatareportingService.observe(null, "app-startup", null);
+ gDatareportingService.observe(null, "profile-after-change", null);
+
+ run_next_test();
+}
+
+add_task(function* () {
+ const drsPath = gDatareportingService._stateFilePath;
+ const fhrDir = OS.Path.join(OS.Constants.Path.profileDir, "healthreport");
+ const fhrPath = OS.Path.join(fhrDir, "state.json");
+ const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
+
+ yield OS.File.makeDir(fhrDir);
+
+ // Check that we are importing the FHR client ID.
+ let clientID = CommonUtils.generateUUID();
+ yield CommonUtils.writeJSON({clientID: clientID}, fhrPath);
+ Assert.equal(clientID, yield gDatareportingService.getClientID());
+
+ // We should persist the ID in DRS now and not pick up a differing ID from FHR.
+ yield gDatareportingService._reset();
+ yield CommonUtils.writeJSON({clientID: CommonUtils.generateUUID()}, fhrPath);
+ Assert.equal(clientID, yield gDatareportingService.getClientID());
+
+ // We should be guarded against broken FHR data.
+ yield gDatareportingService._reset();
+ yield OS.File.remove(drsPath);
+ yield CommonUtils.writeJSON({clientID: -1}, fhrPath);
+ clientID = yield gDatareportingService.getClientID();
+ Assert.equal(typeof(clientID), 'string');
+ Assert.ok(uuidRegex.test(clientID));
+
+ // We should be guarded against invalid FHR json.
+ yield gDatareportingService._reset();
+ yield OS.File.remove(drsPath);
+ yield OS.File.writeAtomic(fhrPath, "abcd", {encoding: "utf-8", tmpPath: fhrPath + ".tmp"});
+ clientID = yield gDatareportingService.getClientID();
+ Assert.equal(typeof(clientID), 'string');
+ Assert.ok(uuidRegex.test(clientID));
+
+ // We should be guarded against broken DRS data too and fall back to loading
+ // the FHR ID.
+ yield gDatareportingService._reset();
+ clientID = CommonUtils.generateUUID();
+ yield CommonUtils.writeJSON({clientID: clientID}, fhrPath);
+ yield CommonUtils.writeJSON({clientID: -1}, drsPath);
+ Assert.equal(clientID, yield gDatareportingService.getClientID());
+
+ // We should be guarded against invalid DRS json too.
+ yield gDatareportingService._reset();
+ yield OS.File.remove(fhrPath);
+ yield OS.File.writeAtomic(drsPath, "abcd", {encoding: "utf-8", tmpPath: drsPath + ".tmp"});
+ clientID = yield gDatareportingService.getClientID();
+ Assert.equal(typeof(clientID), 'string');
+ Assert.ok(uuidRegex.test(clientID));
+
+ // If both the FHR and DSR data are broken, we should end up with a new client ID.
+ yield gDatareportingService._reset();
+ yield CommonUtils.writeJSON({clientID: -1}, fhrPath);
+ yield CommonUtils.writeJSON({clientID: -1}, drsPath);
+ clientID = yield gDatareportingService.getClientID();
+ Assert.equal(typeof(clientID), 'string');
+ Assert.ok(uuidRegex.test(clientID));
+});
--- a/services/datareporting/tests/xpcshell/xpcshell.ini
+++ b/services/datareporting/tests/xpcshell/xpcshell.ini
@@ -1,7 +1,8 @@
[DEFAULT]
head = head.js
tail =
skip-if = toolkit == 'android' || toolkit == 'gonk'
[test_policy.js]
[test_session_recorder.js]
+[test_client_id.js]
--- a/services/healthreport/healthreporter.jsm
+++ b/services/healthreport/healthreporter.jsm
@@ -125,23 +125,28 @@ HealthReporterState.prototype = Object.f
get _lastPayloadPath() {
return OS.Path.join(this._stateDir, "lastpayload.json");
},
init: function () {
return Task.spawn(function* init() {
yield OS.File.makeDir(this._stateDir);
+ let drs = Cc["@mozilla.org/datareporting/service;1"]
+ .getService(Ci.nsISupports)
+ .wrappedJSObject;
+ let drsClientID = yield drs.getClientID();
+
let resetObjectState = function () {
this._s = {
// The payload version. This is bumped whenever there is a
// backwards-incompatible change.
v: 1,
// The persistent client identifier.
- clientID: CommonUtils.generateUUID(),
+ clientID: drsClientID,
// Denotes the mechanism used to generate the client identifier.
// 1: Random UUID.
clientIDVersion: 1,
// Upload IDs that might be on the server.
remoteIDs: [],
// When we last performed an uploaded.
lastPingTime: 0,
// Tracks whether we removed an outdated payload.
@@ -171,32 +176,17 @@ HealthReporterState.prototype = Object.f
if (this._s.v != 1) {
this._log.warn("Unknown version in state file: " + this._s.v);
resetObjectState();
// We explicitly don't save here in the hopes an application re-upgrade
// comes along and fixes us.
}
- let regen = false;
- if (!this._s.clientID) {
- this._log.warn("No client ID stored. Generating random ID.");
- regen = true;
- }
-
- if (typeof(this._s.clientID) != "string") {
- this._log.warn("Client ID is not a string. Regenerating.");
- regen = true;
- }
-
- if (regen) {
- this._s.clientID = CommonUtils.generateUUID();
- this._s.clientIDVersion = 1;
- yield this.save();
- }
+ this._s.clientID = drsClientID;
// Always look for preferences. This ensures that downgrades followed
// by reupgrades don't result in excessive data loss.
for (let promise of this._migratePrefs()) {
yield promise;
}
}.bind(this));
},
@@ -250,31 +240,28 @@ HealthReporterState.prototype = Object.f
this._log.info("Recording last ping time and deleted remote document.");
this._s.lastPingTime = date.getTime();
return this.removeRemoteIDs(ids);
},
/**
* Reset the client ID to something else.
- *
- * This fails if remote IDs are stored because changing the client ID
- * while there is remote data will create orphaned records.
+ * Returns a promise that is resolved when completed.
*/
- resetClientID: function () {
- if (this.remoteIDs.length) {
- throw new Error("Cannot reset client ID while remote IDs are stored.");
- }
+ resetClientID: Task.async(function* () {
+ let drs = Cc["@mozilla.org/datareporting/service;1"]
+ .getService(Ci.nsISupports)
+ .wrappedJSObject;
+ yield drs.resetClientID();
+ this._s.clientID = yield drs.getClientID();
+ this._log.info("Reset client id to " + this._s.clientID + ".");
- this._log.warn("Resetting client ID.");
- this._s.clientID = CommonUtils.generateUUID();
- this._s.clientIDVersion = 1;
-
- return this.save();
- },
+ yield this.save();
+ }),
_migratePrefs: function () {
let prefs = this._reporter._prefs;
let lastID = prefs.get("lastSubmitID", null);
let lastPingDate = CommonUtils.getDatePref(prefs, "lastPingTime",
0, this._log, OLDEST_ALLOWED_YEAR);
--- a/services/healthreport/modules-testing/utils.jsm
+++ b/services/healthreport/modules-testing/utils.jsm
@@ -177,33 +177,43 @@ InspectedHealthReporter.prototype = {
return HealthReporter.prototype._onStorageClose.call(this);
},
};
const DUMMY_URI="http://localhost:62013/";
this.getHealthReporter = function (name, uri=DUMMY_URI, inspected=false) {
+ // The healthreporters use the client id from the datareporting service,
+ // so we need to ensure it is initialized.
+ let drs = Cc["@mozilla.org/datareporting/service;1"]
+ .getService(Ci.nsISupports)
+ .wrappedJSObject;
+ drs.observe(null, "app-startup", null);
+ drs.observe(null, "profile-after-change", null);
+
let branch = "healthreport.testing." + name + ".";
let prefs = new Preferences(branch + "healthreport.");
prefs.set("documentServerURI", uri);
prefs.set("dbName", name);
let reporter;
let policyPrefs = new Preferences(branch + "policy.");
let listener = new MockPolicyListener();
listener.onRequestDataUpload = function (request) {
- reporter.requestDataUpload(request);
+ let promise = reporter.requestDataUpload(request);
MockPolicyListener.prototype.onRequestDataUpload.call(this, request);
+ return promise;
}
listener.onRequestRemoteDelete = function (request) {
- reporter.deleteRemoteData(request);
+ let promise = reporter.deleteRemoteData(request);
MockPolicyListener.prototype.onRequestRemoteDelete.call(this, request);
+ return promise;
}
let policy = new DataReportingPolicy(policyPrefs, prefs, listener);
let type = inspected ? InspectedHealthReporter : HealthReporter;
reporter = new type(branch + "healthreport.", policy, null,
"state-" + name + ".json");
return reporter;
};
--- a/services/healthreport/tests/xpcshell/test_healthreporter.js
+++ b/services/healthreport/tests/xpcshell/test_healthreporter.js
@@ -16,16 +16,22 @@ Cu.import("resource://gre/modules/servic
Cu.import("resource://gre/modules/services/datareporting/policy.jsm");
Cu.import("resource://gre/modules/Services.jsm");
Cu.import("resource://gre/modules/Task.jsm");
Cu.import("resource://testing-common/httpd.js");
Cu.import("resource://testing-common/services/common/bagheeraserver.js");
Cu.import("resource://testing-common/services/metrics/mocks.jsm");
Cu.import("resource://testing-common/services/healthreport/utils.jsm");
Cu.import("resource://testing-common/AppData.jsm");
+Cu.import("resource://gre/modules/XPCOMUtils.jsm");
+
+XPCOMUtils.defineLazyGetter(this, "gDatareportingService",
+ () => Cc["@mozilla.org/datareporting/service;1"]
+ .getService(Ci.nsISupports)
+ .wrappedJSObject);
const DUMMY_URI = "http://localhost:62013/";
const MILLISECONDS_PER_DAY = 24 * 60 * 60 * 1000;
const HealthReporterState = bsp.HealthReporterState;
@@ -103,16 +109,23 @@ function ensureUserNotified (reporter) {
return Task.spawn(function* ensureUserNotified () {
reporter._policy.ensureUserNotified();
yield reporter._policy._listener.lastNotifyRequest.deferred.promise;
do_check_true(reporter._policy.userNotifiedOfCurrentPolicy);
});
}
function run_test() {
+ do_get_profile();
+
+ // Send the needed startup notifications to the datareporting service
+ // to ensure that it has been initialized.
+ gDatareportingService.observe(null, "app-startup", null);
+ gDatareportingService.observe(null, "profile-after-change", null);
+
run_next_test();
}
// run_test() needs to finish synchronously, so we do async init here.
add_task(function test_init() {
yield makeFakeAppDir();
});
@@ -740,17 +753,17 @@ add_task(function test_request_remote_da
let promise = reporter.requestDeleteRemoteData();
do_check_neq(promise, null);
yield promise;
do_check_null(reporter.lastSubmitID);
do_check_false(reporter.haveRemoteData());
do_check_false(server.hasDocument(reporter.serverNamespace, id));
- // Client ID should be updated.
+ // Client ID should be updated.
do_check_neq(reporter._state.clientID, null);
do_check_neq(reporter._state.clientID, clientID);
do_check_eq(reporter._state.clientIDVersion, 1);
// And it should be persisted to disk.
let o = yield CommonUtils.readJSON(reporter._state._filename);
do_check_eq(o.clientID, reporter._state.clientID);
do_check_eq(o.clientIDVersion, 1);
@@ -1166,48 +1179,16 @@ add_task(function test_state_downgrade_u
let o = yield CommonUtils.readJSON(reporter._state._filename);
do_check_eq(o.remoteIDs.length, 3);
do_check_eq(o.lastPingTime, now.getTime() + 1000);
} finally {
yield reporter._shutdown();
}
});
-// Missing client ID in state should be created on state load.
-add_task(function* test_state_create_client_id() {
- let reporter = getHealthReporter("state_create_client_id");
-
- yield CommonUtils.writeJSON({
- v: 1,
- remoteIDs: ["id1", "id2"],
- lastPingTime: Date.now(),
- removeOutdatedLastPayload: true,
- }, reporter._state._filename);
-
- try {
- yield reporter.init();
-
- do_check_eq(reporter.lastSubmitID, "id1");
- do_check_neq(reporter._state.clientID, null);
- do_check_eq(reporter._state.clientID.length, 36);
- do_check_eq(reporter._state.clientIDVersion, 1);
-
- let clientID = reporter._state.clientID;
-
- // The client ID should be persisted as soon as it is created.
- reporter._shutdown();
-
- reporter = getHealthReporter("state_create_client_id");
- yield reporter.init();
- do_check_eq(reporter._state.clientID, clientID);
- } finally {
- reporter._shutdown();
- }
-});
-
// Invalid stored client ID is reset automatically.
add_task(function* test_empty_client_id() {
let reporter = getHealthReporter("state_empty_client_id");
yield CommonUtils.writeJSON({
v: 1,
clientID: "",
remoteIDs: ["id1", "id2"],
--- a/services/healthreport/tests/xpcshell/test_provider_appinfo.js
+++ b/services/healthreport/tests/xpcshell/test_provider_appinfo.js
@@ -1,23 +1,36 @@
/* Any copyright is dedicated to the Public Domain.
* http://creativecommons.org/publicdomain/zero/1.0/ */
"use strict";
-const {interfaces: Ci, results: Cr, utils: Cu} = Components;
+const {interfaces: Ci, results: Cr, utils: Cu, classes: Cc} = Components;
Cu.import("resource://gre/modules/Metrics.jsm");
Cu.import("resource://gre/modules/Services.jsm");
Cu.import("resource://gre/modules/XPCOMUtils.jsm");
Cu.import("resource://gre/modules/services/healthreport/providers.jsm");
Cu.import("resource://testing-common/services/healthreport/utils.jsm");
+Cu.import("resource://gre/modules/XPCOMUtils.jsm");
+
+XPCOMUtils.defineLazyGetter(this, "gDatareportingService",
+ () => Cc["@mozilla.org/datareporting/service;1"]
+ .getService(Ci.nsISupports)
+ .wrappedJSObject);
function run_test() {
+ do_get_profile();
+
+ // Send the needed startup notifications to the datareporting service
+ // to ensure that it has been initialized.
+ gDatareportingService.observe(null, "app-startup", null);
+ gDatareportingService.observe(null, "profile-after-change", null);
+
run_next_test();
}
add_test(function test_constructor() {
let provider = new AppInfoProvider();
run_next_test();
});