summaryrefslogtreecommitdiff
path: root/toolkit/mozapps/extensions/DeferredSave.jsm
blob: 7587ce83bcc61398e475ddf7e380e3ac1bad686b (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
/* 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 Cu = Components.utils;
const Cc = Components.classes;
const Ci = Components.interfaces;

Cu.import("resource://gre/modules/osfile.jsm");
Cu.import("resource://gre/modules/Promise.jsm");

// Make it possible to mock out timers for testing
var MakeTimer = () => Cc["@mozilla.org/timer;1"].createInstance(Ci.nsITimer);

this.EXPORTED_SYMBOLS = ["DeferredSave"];

// If delay parameter is not provided, default is 50 milliseconds.
const DEFAULT_SAVE_DELAY_MS = 50;

Cu.import("resource://gre/modules/Log.jsm");
//Configure a logger at the parent 'DeferredSave' level to format
//messages for all the modules under DeferredSave.*
const DEFERREDSAVE_PARENT_LOGGER_ID = "DeferredSave";
var parentLogger = Log.repository.getLogger(DEFERREDSAVE_PARENT_LOGGER_ID);
parentLogger.level = Log.Level.Warn;
var formatter = new Log.BasicFormatter();
//Set parent logger (and its children) to append to
//the Javascript section of the Browser Console
parentLogger.addAppender(new Log.ConsoleAppender(formatter));
//Set parent logger (and its children) to
//also append to standard out
parentLogger.addAppender(new Log.DumpAppender(formatter));

//Provide the ability to enable/disable logging
//messages at runtime.
//If the "extensions.logging.enabled" preference is
//missing or 'false', messages at the WARNING and higher
//severity should be logged to the JS console and standard error.
//If "extensions.logging.enabled" is set to 'true', messages
//at DEBUG and higher should go to JS console and standard error.
Cu.import("resource://gre/modules/Services.jsm");

const PREF_LOGGING_ENABLED = "extensions.logging.enabled";
const NS_PREFBRANCH_PREFCHANGE_TOPIC_ID = "nsPref:changed";

/**
* Preference listener which listens for a change in the
* "extensions.logging.enabled" preference and changes the logging level of the
* parent 'addons' level logger accordingly.
*/
var PrefObserver = {
 init: function PrefObserver_init() {
   Services.prefs.addObserver(PREF_LOGGING_ENABLED, this, false);
   Services.obs.addObserver(this, "xpcom-shutdown", false);
   this.observe(null, NS_PREFBRANCH_PREFCHANGE_TOPIC_ID, PREF_LOGGING_ENABLED);
 },

 observe: function PrefObserver_observe(aSubject, aTopic, aData) {
   if (aTopic == "xpcom-shutdown") {
     Services.prefs.removeObserver(PREF_LOGGING_ENABLED, this);
     Services.obs.removeObserver(this, "xpcom-shutdown");
   }
   else if (aTopic == NS_PREFBRANCH_PREFCHANGE_TOPIC_ID) {
     let debugLogEnabled = false;
     try {
       debugLogEnabled = Services.prefs.getBoolPref(PREF_LOGGING_ENABLED);
     }
     catch (e) {
     }
     if (debugLogEnabled) {
       parentLogger.level = Log.Level.Debug;
     }
     else {
       parentLogger.level = Log.Level.Warn;
     }
   }
 }
};

PrefObserver.init();

/**
 * A module to manage deferred, asynchronous writing of data files
 * to disk. Writing is deferred by waiting for a specified delay after
 * a request to save the data, before beginning to write. If more than
 * one save request is received during the delay, all requests are
 * fulfilled by a single write.
 *
 * @constructor
 * @param aPath
 *        String representing the full path of the file where the data
 *        is to be written.
 * @param aDataProvider
 *        Callback function that takes no argument and returns the data to
 *        be written. If aDataProvider returns an ArrayBufferView, the
 *        bytes it contains are written to the file as is.
 *        If aDataProvider returns a String the data are UTF-8 encoded
 *        and then written to the file.
 * @param [optional] aDelay
 *        The delay in milliseconds between the first saveChanges() call
 *        that marks the data as needing to be saved, and when the DeferredSave
 *        begins writing the data to disk. Default 50 milliseconds.
 */
this.DeferredSave = function (aPath, aDataProvider, aDelay) {
  // Create a new logger (child of 'DeferredSave' logger)
  // for use by this particular instance of DeferredSave object
  let leafName = OS.Path.basename(aPath);
  let logger_id = DEFERREDSAVE_PARENT_LOGGER_ID + "." + leafName;
  this.logger = Log.repository.getLogger(logger_id);

  // @type {Deferred|null}, null when no data needs to be written
  // @resolves with the result of OS.File.writeAtomic when all writes complete
  // @rejects with the error from OS.File.writeAtomic if the write fails,
  //          or with the error from aDataProvider() if that throws.
  this._pending = null;

  // @type {Promise}, completes when the in-progress write (if any) completes,
  //       kept as a resolved promise at other times to simplify logic.
  //       Because _deferredSave() always uses _writing.then() to execute
  //       its next action, we don't need a special case for whether a write
  //       is in progress - if the previous write is complete (and the _writing
  //       promise is already resolved/rejected), _writing.then() starts
  //       the next action immediately.
  //
  // @resolves with the result of OS.File.writeAtomic
  // @rejects with the error from OS.File.writeAtomic
  this._writing = Promise.resolve(0);

  // Are we currently waiting for a write to complete
  this.writeInProgress = false;

  this._path = aPath;
  this._dataProvider = aDataProvider;

  this._timer = null;

  // Some counters for telemetry
  // The total number of times the file was written
  this.totalSaves = 0;

  // The number of times the data became dirty while
  // another save was in progress
  this.overlappedSaves = 0;

  // Error returned by the most recent write (if any)
  this._lastError = null;

  if (aDelay && (aDelay > 0))
    this._delay = aDelay;
  else
    this._delay = DEFAULT_SAVE_DELAY_MS;
}

this.DeferredSave.prototype = {
  get dirty() {
    return this._pending || this.writeInProgress;
  },

  get lastError() {
    return this._lastError;
  },

  // Start the pending timer if data is dirty
  _startTimer: function() {
    if (!this._pending) {
      return;
    }

      this.logger.debug("Starting timer");
    if (!this._timer)
      this._timer = MakeTimer();
    this._timer.initWithCallback(() => this._deferredSave(),
                                 this._delay, Ci.nsITimer.TYPE_ONE_SHOT);
  },

  /**
   * Mark the current stored data dirty, and schedule a flush to disk
   * @return A Promise<integer> that will be resolved after the data is written to disk;
   *         the promise is resolved with the number of bytes written.
   */
  saveChanges: function() {
      this.logger.debug("Save changes");
    if (!this._pending) {
      if (this.writeInProgress) {
          this.logger.debug("Data changed while write in progress");
        this.overlappedSaves++;
      }
      this._pending = Promise.defer();
      // Wait until the most recent write completes or fails (if it hasn't already)
      // and then restart our timer
      this._writing.then(count => this._startTimer(), error => this._startTimer());
    }
    return this._pending.promise;
  },

  _deferredSave: function() {
    let pending = this._pending;
    this._pending = null;
    let writing = this._writing;
    this._writing = pending.promise;

    // In either the success or the exception handling case, we don't need to handle
    // the error from _writing here; it's already being handled in another then()
    let toSave = null;
    try {
      toSave = this._dataProvider();
    }
    catch(e) {
        this.logger.error("Deferred save dataProvider failed", e);
      writing.then(null, error => {})
        .then(count => {
          pending.reject(e);
        });
      return;
    }

    writing.then(null, error => {return 0;})
    .then(count => {
        this.logger.debug("Starting write");
      this.totalSaves++;
      this.writeInProgress = true;

      OS.File.writeAtomic(this._path, toSave, {tmpPath: this._path + ".tmp"})
      .then(
        result => {
          this._lastError = null;
          this.writeInProgress = false;
              this.logger.debug("Write succeeded");
          pending.resolve(result);
        },
        error => {
          this._lastError = error;
          this.writeInProgress = false;
              this.logger.warn("Write failed", error);
          pending.reject(error);
        });
    });
  },

  /**
   * Immediately save the dirty data to disk, skipping
   * the delay of normal operation. Note that the write
   * still happens asynchronously in the worker
   * thread from OS.File.
   *
   * There are four possible situations:
   * 1) Nothing to flush
   * 2) Data is not currently being written, in-memory copy is dirty
   * 3) Data is currently being written, in-memory copy is clean
   * 4) Data is being written and in-memory copy is dirty
   *
   * @return Promise<integer> that will resolve when all in-memory data
   *         has finished being flushed, returning the number of bytes
   *         written. If all in-memory data is clean, completes with the
   *         result of the most recent write.
   */
  flush: function() {
    // If we have pending changes, cancel our timer and set up the write
    // immediately (_deferredSave queues the write for after the most
    // recent write completes, if it hasn't already)
    if (this._pending) {
        this.logger.debug("Flush called while data is dirty");
      if (this._timer) {
        this._timer.cancel();
        this._timer = null;
      }
      this._deferredSave();
    }

    return this._writing;
  }
};