diff --git a/dom/mobilemessage/tests/marionette/test_strict_7bit_encoding.js b/dom/mobilemessage/tests/marionette/test_strict_7bit_encoding.js index 0dc80f85cbb9..b0da1372144f 100644 --- a/dom/mobilemessage/tests/marionette/test_strict_7bit_encoding.js +++ b/dom/mobilemessage/tests/marionette/test_strict_7bit_encoding.js @@ -116,15 +116,40 @@ SpecialPowers.addPermission("sms", true, document); let sms = window.navigator.mozSms; ok(sms instanceof MozSmsManager); -function repeat(func, array, oncomplete) { - (function do_call(index) { - let next = index < (array.length - 1) ? do_call.bind(null, index + 1) : oncomplete; - array[index].push(next); - func.apply(null, array[index]); - })(0); -} +let tasks = { + // List of test fuctions. Each of them should call |tasks.next()| when + // completed or |tasks.finish()| to jump to the last one. + _tasks: [], + _nextTaskIndex: 0, -function testStrict7BitEncodingHelper(sent, received, next) { + push: function push(func) { + this._tasks.push(func); + }, + + next: function next() { + let index = this._nextTaskIndex++; + let task = this._tasks[index]; + try { + task(); + } catch (ex) { + ok(false, "test task[" + index + "] throws: " + ex); + // Run last task as clean up if possible. + if (index != this._tasks.length - 1) { + this.finish(); + } + } + }, + + finish: function finish() { + this._tasks[this._tasks.length - 1](); + }, + + run: function run() { + this.next(); + } +}; + +function testStrict7BitEncodingHelper(sent, received) { // The log message contains unicode and Marionette seems unable to process // it and throws: |UnicodeEncodeError: 'ascii' codec can't encode character // u'\xa5' in position 14: ordinal not in range(128)|. @@ -135,7 +160,7 @@ function testStrict7BitEncodingHelper(sent, received, next) { function done(step) { count += step; if (count >= 2) { - window.setTimeout(next, 0); + window.setTimeout(tasks.next.bind(tasks), 0); } } @@ -161,53 +186,75 @@ function testStrict7BitEncodingHelper(sent, received, next) { }); } -function test_enabled() { +// Bug 877141 - If you send several spaces together in a sms, the other +// dipositive receives a "*" for each space. +// +// This function is called twice, with strict 7bit encoding enabled or +// disabled. Expect the same result in both sent and received text and with +// either strict 7bit encoding enabled or disabled. +function testBug877141() { + log("Testing bug 877141"); + let sent = "1 2 3"; + testStrict7BitEncodingHelper(sent, sent); +} + +tasks.push(function () { log("Testing with dom.sms.strict7BitEncoding enabled"); - SpecialPowers.setBoolPref("dom.sms.strict7BitEncoding", true); + tasks.next(); +}); - let cases = []; - // Test for combined string. +// Test for combined string. +tasks.push(function () { let sent = "", received = ""; for (let c in GSM_SMS_STRICT_7BIT_CHARMAP) { sent += c; received += GSM_SMS_STRICT_7BIT_CHARMAP[c]; } - cases.push([sent, received]); + testStrict7BitEncodingHelper(sent, received); +}); - // When strict7BitEncoding is enabled, we should replace characters that - // can't be encoded with GSM 7-Bit alphabets with '*'. - cases.push(["\u65b0\u5e74\u5feb\u6a02", "****"]); // "Happy New Year" in Chinese. +// When strict7BitEncoding is enabled, we should replace characters that +// can't be encoded with GSM 7-Bit alphabets with '*'. +tasks.push(function () { + // "Happy New Year" in Chinese. + let sent = "\u65b0\u5e74\u5feb\u6a02", received = "****"; + testStrict7BitEncodingHelper(sent, received); +}); - repeat(testStrict7BitEncodingHelper, cases, test_disabled); -} +tasks.push(testBug877141); -function test_disabled() { +tasks.push(function () { log("Testing with dom.sms.strict7BitEncoding disabled"); - SpecialPowers.setBoolPref("dom.sms.strict7BitEncoding", false); + tasks.next(); +}); - let cases = []; - - // Test for combined string. +// Test for combined string. +tasks.push(function () { let sent = ""; for (let c in GSM_SMS_STRICT_7BIT_CHARMAP) { sent += c; } - cases.push([sent, sent]); + testStrict7BitEncodingHelper(sent, sent); +}); - cases.push(["\u65b0\u5e74\u5feb\u6a02", "\u65b0\u5e74\u5feb\u6a02"]); +tasks.push(function () { + // "Happy New Year" in Chinese. + let sent = "\u65b0\u5e74\u5feb\u6a02"; + testStrict7BitEncodingHelper(sent, sent); +}); - repeat(testStrict7BitEncodingHelper, cases, cleanUp); -} +tasks.push(testBug877141); -function cleanUp() { +// WARNING: All tasks should be pushed before this!!! +tasks.push(function cleanUp() { SpecialPowers.removePermission("sms", document); SpecialPowers.clearUserPref("dom.sms.enabled"); SpecialPowers.clearUserPref("dom.sms.strict7BitEncoding"); finish(); -} +}); -test_enabled(); +tasks.run(); diff --git a/dom/system/gonk/NetworkManager.js b/dom/system/gonk/NetworkManager.js index 3affc853603f..fa7c4290b15a 100644 --- a/dom/system/gonk/NetworkManager.js +++ b/dom/system/gonk/NetworkManager.js @@ -130,6 +130,13 @@ function isComplete(code) { return (type != NETD_COMMAND_PROCEEDING); } +function defineLazyRegExp(obj, name, pattern) { + obj.__defineGetter__(name, function() { + delete obj[name]; + return obj[name] = new RegExp(pattern); + }); +} + /** * This component watches for network interfaces changing state and then * adjusts routes etc. accordingly. @@ -216,6 +223,10 @@ function NetworkManager() { }); ppmm.addMessageListener('NetworkInterfaceList:ListInterface', this); + + // Used in resolveHostname(). + defineLazyRegExp(this, "REGEXP_IPV4", "^\\d{1,3}(?:\\.\\d{1,3}){3}$"); + defineLazyRegExp(this, "REGEXP_IPV6", "^[\\da-fA-F]{4}(?::[\\da-fA-F]{4}){7}$"); } NetworkManager.prototype = { classID: NETWORKMANAGER_CID, @@ -611,17 +622,25 @@ NetworkManager.prototype = { resolveHostname: function resolveHostname(hosts) { let retval = []; - for(var i = 0; i < hosts.length; i++) { - let hostname = hosts[i].split('/')[2]; - if (!hostname) { + for (let hostname of hosts) { + try { + let uri = Services.io.newURI(hostname, null, null); + hostname = uri.host; + } catch (e) {} + + if (hostname.match(this.REGEXP_IPV4) || + hostname.match(this.REGEXP_IPV6)) { + retval.push(hostname); continue; } - let hostnameIps = gDNSService.resolve(hostname, 0); - while (hostnameIps.hasMore()) { - retval.push(hostnameIps.getNextAddrAsString()); - debug("Found IP at: " + JSON.stringify(retval)); - } + try { + let hostnameIps = gDNSService.resolve(hostname, 0); + while (hostnameIps.hasMore()) { + retval.push(hostnameIps.getNextAddrAsString()); + debug("Found IP at: " + JSON.stringify(retval)); + } + } catch (e) {} } return retval; diff --git a/hal/gonk/GonkHal.cpp b/hal/gonk/GonkHal.cpp index 8c2dce347082..0a60cf7da30d 100644 --- a/hal/gonk/GonkHal.cpp +++ b/hal/gonk/GonkHal.cpp @@ -1166,7 +1166,7 @@ SetNiceForPid(int aPid, int aNice) } int newtaskpriority = - std::max(origtaskpriority + aNice - origProcPriority, origProcPriority); + std::max(origtaskpriority - origProcPriority + aNice, aNice); rv = setpriority(PRIO_PROCESS, tid, newtaskpriority); if (rv) { diff --git a/mobile/android/base/GeckoApp.java b/mobile/android/base/GeckoApp.java index 910d50ea58d9..5bdd58928c3f 100644 --- a/mobile/android/base/GeckoApp.java +++ b/mobile/android/base/GeckoApp.java @@ -16,6 +16,7 @@ import org.mozilla.gecko.menu.GeckoMenu; import org.mozilla.gecko.menu.GeckoMenuInflater; import org.mozilla.gecko.menu.MenuPanel; import org.mozilla.gecko.health.BrowserHealthRecorder; +import org.mozilla.gecko.health.BrowserHealthRecorder.SessionInformation; import org.mozilla.gecko.updater.UpdateService; import org.mozilla.gecko.updater.UpdateServiceHelper; import org.mozilla.gecko.util.EventDispatcher; @@ -248,6 +249,10 @@ abstract public class GeckoApp return this; } + public static SharedPreferences getAppSharedPreferences() { + return GeckoApp.sAppContext.getSharedPreferences(PREFS_NAME, 0); + } + public SurfaceView getCameraView() { return mCameraView; } @@ -544,6 +549,11 @@ abstract public class GeckoApp } else if (event.equals("Gecko:Ready")) { mGeckoReadyStartupTimer.stop(); geckoConnected(); + + // This method is already running on the background thread, so we + // know that mHealthRecorder will exist. This method is cheap, so + // don't spawn a new runnable. + mHealthRecorder.recordGeckoStartupTime(mGeckoReadyStartupTimer.getElapsed()); } else if (event.equals("ToggleChrome:Hide")) { toggleChrome(false); } else if (event.equals("ToggleChrome:Show")) { @@ -1240,20 +1250,20 @@ abstract public class GeckoApp ThreadUtils.postToBackgroundThread(new Runnable() { @Override public void run() { - SharedPreferences prefs = - GeckoApp.sAppContext.getSharedPreferences(PREFS_NAME, 0); + final SharedPreferences prefs = GeckoApp.getAppSharedPreferences(); - boolean wasOOM = prefs.getBoolean(PREFS_OOM_EXCEPTION, false); - boolean wasStopped = prefs.getBoolean(PREFS_WAS_STOPPED, true); - if (wasOOM || !wasStopped) { + SessionInformation previousSession = SessionInformation.fromSharedPrefs(prefs); + if (previousSession.wasKilled()) { Telemetry.HistogramAdd("FENNEC_WAS_KILLED", 1); } + SharedPreferences.Editor editor = prefs.edit(); editor.putBoolean(GeckoApp.PREFS_OOM_EXCEPTION, false); - // Put a flag to check if we got a normal onSaveInstanceState - // on exit, or if we were suddenly killed (crash or native OOM) + // Put a flag to check if we got a normal `onSaveInstanceState` + // on exit, or if we were suddenly killed (crash or native OOM). editor.putBoolean(GeckoApp.PREFS_WAS_STOPPED, false); + editor.commit(); // The lifecycle of mHealthRecorder is "shortly after onCreate" @@ -1262,7 +1272,8 @@ abstract public class GeckoApp final String profilePath = getProfile().getDir().getAbsolutePath(); final EventDispatcher dispatcher = GeckoAppShell.getEventDispatcher(); Log.i(LOGTAG, "Creating BrowserHealthRecorder."); - mHealthRecorder = new BrowserHealthRecorder(sAppContext, profilePath, dispatcher); + mHealthRecorder = new BrowserHealthRecorder(sAppContext, profilePath, dispatcher, + previousSession); } }); @@ -1473,12 +1484,16 @@ abstract public class GeckoApp } }); - // End of the startup of our Java App + // Trigger the completion of the telemetry timer that wraps activity startup, + // then grab the duration to give to FHR. mJavaUiStartupTimer.stop(); + final long javaDuration = mJavaUiStartupTimer.getElapsed(); ThreadUtils.getBackgroundHandler().postDelayed(new Runnable() { @Override public void run() { + mHealthRecorder.recordJavaStartupTime(javaDuration); + // Sync settings need Gecko to be loaded, so // no hurry in starting this. checkMigrateSync(); @@ -1593,7 +1608,7 @@ abstract public class GeckoApp return RESTORE_OOM; } - final SharedPreferences prefs = GeckoApp.sAppContext.getSharedPreferences(PREFS_NAME, 0); + final SharedPreferences prefs = GeckoApp.getAppSharedPreferences(); // We record crashes in the crash reporter. If sessionstore.js // exists, but we didn't flag a crash in the crash reporter, we @@ -1792,19 +1807,34 @@ abstract public class GeckoApp GeckoAccessibility.updateAccessibilitySettings(this); if (mAppStateListeners != null) { - for(GeckoAppShell.AppStateListener listener: mAppStateListeners) { + for (GeckoAppShell.AppStateListener listener: mAppStateListeners) { listener.onResume(); } } + // We use two times: a pseudo-unique wall-clock time to identify the + // current session across power cycles, and the elapsed realtime to + // track the duration of the session. + final long now = System.currentTimeMillis(); + final long realTime = android.os.SystemClock.elapsedRealtime(); + final BrowserHealthRecorder rec = mHealthRecorder; + ThreadUtils.postToBackgroundThread(new Runnable() { @Override public void run() { - SharedPreferences prefs = - GeckoApp.sAppContext.getSharedPreferences(GeckoApp.PREFS_NAME, 0); + // Now construct the new session on BrowserHealthRecorder's behalf. We do this here + // so it can benefit from a single near-startup prefs commit. + SessionInformation currentSession = new SessionInformation(now, realTime); + + SharedPreferences prefs = GeckoApp.getAppSharedPreferences(); SharedPreferences.Editor editor = prefs.edit(); editor.putBoolean(GeckoApp.PREFS_WAS_STOPPED, false); + currentSession.recordBegin(editor); editor.commit(); + + if (rec != null) { + rec.setCurrentSession(currentSession); + } } }); } @@ -1822,16 +1852,20 @@ abstract public class GeckoApp @Override public void onPause() { + final BrowserHealthRecorder rec = mHealthRecorder; + // In some way it's sad that Android will trigger StrictMode warnings // here as the whole point is to save to disk while the activity is not // interacting with the user. ThreadUtils.postToBackgroundThread(new Runnable() { @Override public void run() { - SharedPreferences prefs = - GeckoApp.sAppContext.getSharedPreferences(GeckoApp.PREFS_NAME, 0); + SharedPreferences prefs = GeckoApp.getAppSharedPreferences(); SharedPreferences.Editor editor = prefs.edit(); editor.putBoolean(GeckoApp.PREFS_WAS_STOPPED, true); + if (rec != null) { + rec.recordSessionEnd("P", editor); + } editor.commit(); } }); @@ -1853,8 +1887,7 @@ abstract public class GeckoApp ThreadUtils.postToBackgroundThread(new Runnable() { @Override public void run() { - SharedPreferences prefs = - GeckoApp.sAppContext.getSharedPreferences(GeckoApp.PREFS_NAME, 0); + SharedPreferences prefs = GeckoApp.getAppSharedPreferences(); SharedPreferences.Editor editor = prefs.edit(); editor.putBoolean(GeckoApp.PREFS_WAS_STOPPED, false); editor.commit(); @@ -1927,9 +1960,16 @@ abstract public class GeckoApp SmsManager.getInstance().shutdown(); } - if (mHealthRecorder != null) { - mHealthRecorder.close(); - mHealthRecorder = null; + final BrowserHealthRecorder rec = mHealthRecorder; + mHealthRecorder = null; + if (rec != null) { + // Closing a BrowserHealthRecorder could incur a write. + ThreadUtils.postToBackgroundThread(new Runnable() { + @Override + public void run() { + rec.close(); + } + }); } super.onDestroy(); diff --git a/mobile/android/base/Telemetry.java b/mobile/android/base/Telemetry.java index 256f418f61a1..ae115cb459d7 100644 --- a/mobile/android/base/Telemetry.java +++ b/mobile/android/base/Telemetry.java @@ -36,6 +36,7 @@ public class Telemetry { private long mStartTime; private String mName; private boolean mHasFinished; + private volatile long mElapsed = -1; public Timer(String name) { mName = name; @@ -47,6 +48,10 @@ public class Telemetry { mHasFinished = true; } + public long getElapsed() { + return mElapsed; + } + public void stop() { // Only the first stop counts. if (mHasFinished) { @@ -55,11 +60,12 @@ public class Telemetry { mHasFinished = true; } - long elapsed = SystemClock.uptimeMillis() - mStartTime; + final long elapsed = SystemClock.uptimeMillis() - mStartTime; + mElapsed = elapsed; if (elapsed < Integer.MAX_VALUE) { HistogramAdd(mName, (int)(elapsed)); } else { - Log.e(LOGTAG, "Duration of " + elapsed + " ms is too long."); + Log.e(LOGTAG, "Duration of " + elapsed + " ms is too long to add to histogram."); } } } diff --git a/mobile/android/base/background/healthreport/Environment.java b/mobile/android/base/background/healthreport/Environment.java index 211b28ca2f75..41b8a4117b94 100644 --- a/mobile/android/base/background/healthreport/Environment.java +++ b/mobile/android/base/background/healthreport/Environment.java @@ -241,6 +241,10 @@ public abstract class Environment { } public void setJSONForAddons(String json) throws Exception { + if (json == null || "null".equals(json)) { + addons = null; + return; + } addons = new JSONObject(json); } diff --git a/mobile/android/base/background/healthreport/HealthReportDatabaseStorage.java b/mobile/android/base/background/healthreport/HealthReportDatabaseStorage.java index fa9cc366d0bd..5e71e6b2bfd7 100644 --- a/mobile/android/base/background/healthreport/HealthReportDatabaseStorage.java +++ b/mobile/android/base/background/healthreport/HealthReportDatabaseStorage.java @@ -11,6 +11,7 @@ import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.Executor; import java.util.concurrent.Executors; +import org.json.JSONObject; import org.mozilla.gecko.background.common.log.Logger; import org.mozilla.gecko.background.healthreport.HealthReportStorage.MeasurementFields.FieldSpec; @@ -215,7 +216,7 @@ public class HealthReportDatabaseStorage implements HealthReportStorage { int mode, SQLiteDatabase.CursorFactory factory) { final File path = getDatabasePath(name); - Logger.info(LOG_TAG, "Opening database through absolute path " + path.getAbsolutePath()); + Logger.pii(LOG_TAG, "Opening database through absolute path " + path.getAbsolutePath()); return SQLiteDatabase.openOrCreateDatabase(path, null); } } @@ -233,7 +234,7 @@ public class HealthReportDatabaseStorage implements HealthReportStorage { CURRENT_VERSION); if (CAN_USE_ABSOLUTE_DB_PATH) { - Logger.info(LOG_TAG, "Opening: " + getAbsolutePath(profileDirectory, name)); + Logger.pii(LOG_TAG, "Opening: " + getAbsolutePath(profileDirectory, name)); } } @@ -1034,6 +1035,11 @@ public class HealthReportDatabaseStorage implements HealthReportStorage { } } + @Override + public void recordDailyLast(int env, int day, int field, JSONObject value) { + this.recordDailyLast(env, day, field, value == null ? "null" : value.toString(), EVENTS_TEXTUAL); + } + @Override public void recordDailyLast(int env, int day, int field, String value) { this.recordDailyLast(env, day, field, value, EVENTS_TEXTUAL); @@ -1060,6 +1066,11 @@ public class HealthReportDatabaseStorage implements HealthReportStorage { db.insert(table, null, v); } + @Override + public void recordDailyDiscrete(int env, int day, int field, JSONObject value) { + this.recordDailyDiscrete(env, day, field, value == null ? "null" : value.toString(), EVENTS_TEXTUAL); + } + @Override public void recordDailyDiscrete(int env, int day, int field, String value) { this.recordDailyDiscrete(env, day, field, value, EVENTS_TEXTUAL); diff --git a/mobile/android/base/background/healthreport/HealthReportGenerator.java b/mobile/android/base/background/healthreport/HealthReportGenerator.java index c689e8f01601..0b791768834d 100644 --- a/mobile/android/base/background/healthreport/HealthReportGenerator.java +++ b/mobile/android/base/background/healthreport/HealthReportGenerator.java @@ -112,7 +112,7 @@ public class HealthReportGenerator { JSONObject days = new JSONObject(); Cursor cursor = storage.getRawEventsSince(since); try { - if (!cursor.moveToNext()) { + if (!cursor.moveToFirst()) { return days; } @@ -186,6 +186,22 @@ public class HealthReportGenerator { return days; } + /** + * Return the {@link JSONObject} parsed from the provided index of the given + * cursor, or {@link JSONObject#NULL} if either SQL NULL or + * string "null" is present at that index. + */ + private static Object getJSONAtIndex(Cursor cursor, int index) throws JSONException { + if (cursor.isNull(index)) { + return JSONObject.NULL; + } + final String value = cursor.getString(index); + if ("null".equals(value)) { + return JSONObject.NULL; + } + return new JSONObject(value); + } + protected static void recordMeasurementFromCursor(final Field field, JSONObject measurement, Cursor cursor) @@ -205,6 +221,10 @@ public class HealthReportGenerator { HealthReportUtils.append(measurement, field.fieldName, cursor.getString(3)); return; } + if (field.isJSONField()) { + HealthReportUtils.append(measurement, field.fieldName, getJSONAtIndex(cursor, 3)); + return; + } if (field.isIntegerField()) { HealthReportUtils.append(measurement, field.fieldName, cursor.getLong(3)); return; @@ -217,6 +237,10 @@ public class HealthReportGenerator { measurement.put(field.fieldName, cursor.getString(3)); return; } + if (field.isJSONField()) { + measurement.put(field.fieldName, getJSONAtIndex(cursor, 3)); + return; + } measurement.put(field.fieldName, cursor.getLong(3)); } diff --git a/mobile/android/base/background/healthreport/HealthReportStorage.java b/mobile/android/base/background/healthreport/HealthReportStorage.java index 7f19f5bdd20a..262c0fbcff6b 100644 --- a/mobile/android/base/background/healthreport/HealthReportStorage.java +++ b/mobile/android/base/background/healthreport/HealthReportStorage.java @@ -4,6 +4,8 @@ package org.mozilla.gecko.background.healthreport; +import org.json.JSONObject; + import android.database.Cursor; import android.util.SparseArray; @@ -29,6 +31,7 @@ public interface HealthReportStorage { protected static final int FLAG_INTEGER = 1 << 0; protected static final int FLAG_STRING = 1 << 1; + protected static final int FLAG_JSON = 1 << 2; protected static final int FLAG_DISCRETE = 1 << 8; protected static final int FLAG_LAST = 1 << 9; @@ -43,6 +46,9 @@ public interface HealthReportStorage { public static final int TYPE_STRING_DISCRETE = FLAG_STRING | FLAG_DISCRETE; public static final int TYPE_STRING_LAST = FLAG_STRING | FLAG_LAST; + public static final int TYPE_JSON_DISCRETE = FLAG_JSON | FLAG_DISCRETE; + public static final int TYPE_JSON_LAST = FLAG_JSON | FLAG_LAST; + public static final int TYPE_COUNTED_STRING_DISCRETE = FLAG_COUNTED | TYPE_STRING_DISCRETE; protected int fieldID = UNKNOWN_TYPE_OR_FIELD_ID; @@ -73,6 +79,14 @@ public interface HealthReportStorage { return (this.flags & FLAG_STRING) > 0; } + public boolean isJSONField() { + return (this.flags & FLAG_JSON) > 0; + } + + public boolean isStoredAsString() { + return (this.flags & (FLAG_JSON | FLAG_STRING)) > 0; + } + public boolean isDiscreteField() { return (this.flags & FLAG_DISCRETE) > 0; } @@ -154,8 +168,10 @@ public interface HealthReportStorage { */ public SparseArray getFieldsByID(); + public void recordDailyLast(int env, int day, int field, JSONObject value); public void recordDailyLast(int env, int day, int field, String value); public void recordDailyLast(int env, int day, int field, int value); + public void recordDailyDiscrete(int env, int day, int field, JSONObject value); public void recordDailyDiscrete(int env, int day, int field, String value); public void recordDailyDiscrete(int env, int day, int field, int value); public void incrementDailyCount(int env, int day, int field, int by); diff --git a/mobile/android/base/health/BrowserHealthRecorder.java b/mobile/android/base/health/BrowserHealthRecorder.java index 224f0850999f..b640b207ec09 100644 --- a/mobile/android/base/health/BrowserHealthRecorder.java +++ b/mobile/android/base/health/BrowserHealthRecorder.java @@ -9,9 +9,11 @@ import java.util.ArrayList; import android.content.Context; import android.content.ContentProviderClient; +import android.content.SharedPreferences; import android.util.Log; import org.mozilla.gecko.AppConstants; +import org.mozilla.gecko.GeckoApp; import org.mozilla.gecko.GeckoAppShell; import org.mozilla.gecko.GeckoEvent; import org.mozilla.gecko.PrefsHelper; @@ -28,6 +30,7 @@ import org.mozilla.gecko.util.EventDispatcher; import org.mozilla.gecko.util.GeckoEventListener; import org.mozilla.gecko.util.ThreadUtils; +import org.json.JSONException; import org.json.JSONObject; import java.io.File; @@ -36,6 +39,7 @@ import java.io.OutputStreamWriter; import java.nio.charset.Charset; import java.util.ArrayList; import java.util.Scanner; +import java.util.concurrent.atomic.AtomicBoolean; /** * BrowserHealthRecorder is the browser's interface to the Firefox Health @@ -52,7 +56,7 @@ import java.util.Scanner; * * Use it to record events: {@link #recordSearch(String, String)}. * - * Shut it down when you're done being a browser: {@link #close(EventDispatcher)}. + * Shut it down when you're done being a browser: {@link #close()}. */ public class BrowserHealthRecorder implements GeckoEventListener { private static final String LOG_TAG = "GeckoHealthRec"; @@ -78,12 +82,139 @@ public class BrowserHealthRecorder implements GeckoEventListener { protected volatile State state = State.NOT_INITIALIZED; + private final AtomicBoolean orphanChecked = new AtomicBoolean(false); private volatile int env = -1; private volatile HealthReportDatabaseStorage storage; private final ProfileInformationCache profileCache; private ContentProviderClient client; private final EventDispatcher dispatcher; + public static class SessionInformation { + private static final String LOG_TAG = "GeckoSessInfo"; + + public static final String PREFS_SESSION_START = "sessionStart"; + + public final long wallStartTime; // System wall clock. + public final long realStartTime; // Realtime clock. + + private final boolean wasOOM; + private final boolean wasStopped; + + private volatile long timedGeckoStartup = -1; + private volatile long timedJavaStartup = -1; + + // Current sessions don't (right now) care about wasOOM/wasStopped. + // Eventually we might want to lift that logic out of GeckoApp. + public SessionInformation(long wallTime, long realTime) { + this(wallTime, realTime, false, false); + } + + // Previous sessions do... + public SessionInformation(long wallTime, long realTime, boolean wasOOM, boolean wasStopped) { + this.wallStartTime = wallTime; + this.realStartTime = realTime; + this.wasOOM = wasOOM; + this.wasStopped = wasStopped; + } + + /** + * Initialize a new SessionInformation instance from the supplied prefs object. + * + * This includes retrieving OOM/crash data, as well as timings. + * + * If no wallStartTime was found, that implies that the previous + * session was correctly recorded, and an object with a zero + * wallStartTime is returned. + */ + public static SessionInformation fromSharedPrefs(SharedPreferences prefs) { + boolean wasOOM = prefs.getBoolean(GeckoApp.PREFS_OOM_EXCEPTION, false); + boolean wasStopped = prefs.getBoolean(GeckoApp.PREFS_WAS_STOPPED, true); + long wallStartTime = prefs.getLong(PREFS_SESSION_START, 0L); + long realStartTime = 0L; + Log.d(LOG_TAG, "Building SessionInformation from prefs: " + + wallStartTime + ", " + realStartTime + ", " + + wasStopped + ", " + wasOOM); + return new SessionInformation(wallStartTime, realStartTime, wasOOM, wasStopped); + } + + public boolean wasKilled() { + return wasOOM || !wasStopped; + } + + /** + * Record the beginning of this session to SharedPreferences by + * recording our start time. If a session was already recorded, it is + * overwritten (there can only be one running session at a time). Does + * not commit the editor. + */ + public void recordBegin(SharedPreferences.Editor editor) { + Log.d(LOG_TAG, "Recording start of session: " + this.wallStartTime); + editor.putLong(PREFS_SESSION_START, this.wallStartTime); + } + + /** + * Record the completion of this session to SharedPreferences by + * deleting our start time. Does not commit the editor. + */ + public void recordCompletion(SharedPreferences.Editor editor) { + Log.d(LOG_TAG, "Recording session done: " + this.wallStartTime); + editor.remove(PREFS_SESSION_START); + } + + /** + * Return the JSON that we'll put in the DB for this session. + */ + public JSONObject getCompletionJSON(String reason, long realEndTime) throws JSONException { + long durationSecs = (realEndTime - this.realStartTime) / 1000; + JSONObject out = new JSONObject(); + out.put("r", reason); + out.put("d", durationSecs); + if (this.timedGeckoStartup > 0) { + out.put("sg", this.timedGeckoStartup); + } + if (this.timedJavaStartup > 0) { + out.put("sj", this.timedJavaStartup); + } + return out; + } + + public JSONObject getCrashedJSON() throws JSONException { + JSONObject out = new JSONObject(); + // We use ints here instead of booleans, because we're packing + // stuff into JSON, and saving bytes in the DB is a worthwhile + // goal. + out.put("oom", this.wasOOM ? 1 : 0); + out.put("stopped", this.wasStopped ? 1 : 0); + out.put("r", "A"); + return out; + } + } + + // We track previousSession to avoid order-of-initialization confusion. We + // accept it in the constructor, and process it after init. + private final SessionInformation previousSession; + private volatile SessionInformation session = null; + public SessionInformation getCurrentSession() { + return this.session; + } + + public void setCurrentSession(SessionInformation session) { + this.session = session; + } + + public void recordGeckoStartupTime(long duration) { + if (this.session == null) { + return; + } + this.session.timedGeckoStartup = duration; + } + public void recordJavaStartupTime(long duration) { + if (this.session == null) { + return; + } + this.session.timedJavaStartup = duration; + } + /** * Persist the opaque identifier for the current Firefox Health Report environment. * This changes in certain circumstances; be sure to use the current value when recording data. @@ -95,9 +226,11 @@ public class BrowserHealthRecorder implements GeckoEventListener { /** * This constructor does IO. Run it on a background thread. */ - public BrowserHealthRecorder(final Context context, final String profilePath, final EventDispatcher dispatcher) { + public BrowserHealthRecorder(final Context context, final String profilePath, final EventDispatcher dispatcher, SessionInformation previousSession) { Log.d(LOG_TAG, "Initializing. Dispatcher is " + dispatcher); this.dispatcher = dispatcher; + this.previousSession = previousSession; + this.client = EnvironmentBuilder.getContentProviderClient(context); if (this.client == null) { throw new IllegalStateException("Could not fetch Health Report content provider."); @@ -114,8 +247,6 @@ public class BrowserHealthRecorder implements GeckoEventListener { } catch (Exception e) { Log.e(LOG_TAG, "Exception initializing.", e); } - - // TODO: record session start and end? } /** @@ -353,6 +484,7 @@ public class BrowserHealthRecorder implements GeckoEventListener { dispatcher.registerEventListener(EVENT_PREF_CHANGE, self); // Initialize each provider here. + initializeSessionsProvider(); initializeSearchProvider(); Log.d(LOG_TAG, "Ensuring environment."); @@ -365,7 +497,11 @@ public class BrowserHealthRecorder implements GeckoEventListener { state = State.INITIALIZATION_FAILED; storage.abortInitialization(); Log.e(LOG_TAG, "Initialization failed.", e); + return; } + + // Now do whatever we do after we start up. + checkForOrphanSessions(); } } }); @@ -383,7 +519,7 @@ public class BrowserHealthRecorder implements GeckoEventListener { // If we can restore state from last time, great. if (this.profileCache.restoreUnlessInitialized()) { - Log.i(LOG_TAG, "Successfully restored state. Initializing storage."); + Log.d(LOG_TAG, "Successfully restored state. Initializing storage."); initializeStorage(); return; } @@ -641,5 +777,155 @@ public class BrowserHealthRecorder implements GeckoEventListener { } }); } + + /* + * Sessions. + * + * We record session beginnings in SharedPreferences, because it's cheaper + * to do that than to either write to then update the DB (which requires + * keeping a row identifier to update, as well as two writes) or to record + * two events (which doubles storage space and requires rollup logic). + * + * The pattern is: + * + * 1. On startup, determine whether an orphan session exists by looking for + * a saved timestamp in prefs. If it does, then record the orphan in FHR + * storage. + * 2. Record in prefs that a new session has begun. Track the timestamp (so + * we know to which day the session belongs). + * 3. As startup timings become available, accumulate them in memory. + * 4. On clean shutdown, read the values from here, write them to the DB, and + * delete the sentinel time from SharedPreferences. + * 5. On a dirty shutdown, the in-memory session will not be written to the + * DB, and the current session will be orphaned. + * + * Sessions are begun in onResume (and thus implicitly onStart) and ended + * in onPause. + * + * Session objects are stored as discrete JSON. + * + * "org.mozilla.appSessions": { + * _v: 4, + * "normal": [ + * {"r":"P", "d": 123}, + * ], + * "abnormal": [ + * {"r":"A", "oom": true, "stopped": false} + * ] + * } + * + * "r": reason. Values are "P" (activity paused), "A" (abnormal termination) + * "d": duration. Value in seconds. + * "sg": Gecko startup time. Present if this is a clean launch. + * "sj": Java startup time. Present if this is a clean launch. + * + * Abnormal terminations will be missing a duration and will feature these keys: + * + * "oom": was the session killed by an OOM exception? + * "stopped": was the session stopped gently? + */ + + public static final String MEASUREMENT_NAME_SESSIONS = "org.mozilla.appSessions"; + public static final int MEASUREMENT_VERSION_SESSIONS = 4; + + private void initializeSessionsProvider() { + this.storage.ensureMeasurementInitialized( + MEASUREMENT_NAME_SESSIONS, + MEASUREMENT_VERSION_SESSIONS, + new MeasurementFields() { + @Override + public Iterable getFields() { + ArrayList out = new ArrayList(2); + out.add(new FieldSpec("normal", Field.TYPE_JSON_DISCRETE)); + out.add(new FieldSpec("abnormal", Field.TYPE_JSON_DISCRETE)); + return out; + } + }); + } + + /** + * Logic shared between crashed and normal sessions. + */ + private void recordSessionEntry(String field, SessionInformation session, JSONObject value) { + try { + final int sessionField = storage.getField(MEASUREMENT_NAME_SESSIONS, + MEASUREMENT_VERSION_SESSIONS, + field) + .getID(); + final int day = storage.getDay(session.wallStartTime); + storage.recordDailyDiscrete(env, day, sessionField, value); + } catch (Exception e) { + Log.w(LOG_TAG, "Unable to record session completion.", e); + } + } + + public void checkForOrphanSessions() { + if (!this.orphanChecked.compareAndSet(false, true)) { + Log.w(LOG_TAG, "Attempting to check for orphan sessions more than once."); + return; + } + + Log.d(LOG_TAG, "Checking for orphan session."); + if (this.previousSession == null) { + return; + } + if (this.previousSession.wallStartTime == 0) { + return; + } + + if (state != State.INITIALIZED) { + // Something has gone awry. + Log.e(LOG_TAG, "Attempted to record bad session end without initialized recorder."); + return; + } + + try { + recordSessionEntry("abnormal", this.previousSession, this.previousSession.getCrashedJSON()); + } catch (Exception e) { + Log.w(LOG_TAG, "Unable to generate session JSON.", e); + + // Future: record this exception in FHR's own error submitter. + } + } + + /** + * Record that the current session ended. Does not commit the provided editor. + */ + public void recordSessionEnd(String reason, SharedPreferences.Editor editor) { + Log.d(LOG_TAG, "Recording session end: " + reason); + if (state != State.INITIALIZED) { + // Something has gone awry. + Log.e(LOG_TAG, "Attempted to record session end without initialized recorder."); + return; + } + + final SessionInformation session = this.session; + this.session = null; // So it can't be double-recorded. + + if (session == null) { + Log.w(LOG_TAG, "Unable to record session end: no session. Already ended?"); + return; + } + + if (session.wallStartTime <= 0) { + Log.e(LOG_TAG, "Session start " + session.wallStartTime + " isn't valid! Can't record end."); + return; + } + + long realEndTime = android.os.SystemClock.elapsedRealtime(); + try { + JSONObject json = session.getCompletionJSON(reason, realEndTime); + recordSessionEntry("normal", session, json); + } catch (JSONException e) { + Log.w(LOG_TAG, "Unable to generate session JSON.", e); + + // Continue so we don't hit it next time. + // Future: record this exception in FHR's own error submitter. + } + + // Track the end of this session in shared prefs, so it doesn't get + // double-counted on next run. + session.recordCompletion(editor); + } } diff --git a/toolkit/devtools/server/tests/unit/test_sourcemaps-02.js b/toolkit/devtools/server/tests/unit/test_sourcemaps-02.js index c2df4b632e16..bc60f56fdafe 100644 --- a/toolkit/devtools/server/tests/unit/test_sourcemaps-02.js +++ b/toolkit/devtools/server/tests/unit/test_sourcemaps-02.js @@ -35,23 +35,18 @@ function test_simple_source_map() let numNewSources = 0; - gClient.addListener("newSource", function _onNewSource(aEvent, aPacket) { - if (++numNewSources !== 3) { - return; - } - gClient.removeListener("newSource", _onNewSource); - + gClient.addOneTimeListener("paused", function (aEvent, aPacket) { gThreadClient.getSources(function (aResponse) { do_check_true(!aResponse.error, "Should not get an error"); for (let s of aResponse.sources) { - do_check_true(expectedSources.has(s.url), - "The source's url should be one of our original sources"); + do_check_neq(s.url, "http://example.com/www/js/abc.js", + "Shouldn't get the generated source's url.") expectedSources.delete(s.url); } do_check_eq(expectedSources.size, 0, - "Shouldn't be expecting any more sources"); + "Should have found all the expected sources sources by now."); finishClient(gClient); }); @@ -61,6 +56,7 @@ function test_simple_source_map() new SourceNode(1, 0, "a.js", "function a() { return 'a'; }\n"), new SourceNode(1, 0, "b.js", "function b() { return 'b'; }\n"), new SourceNode(1, 0, "c.js", "function c() { return 'c'; }\n"), + "debugger;\n" ])).toStringWithSourceMap({ file: "abc.js", sourceRoot: "http://example.com/www/js/"