|
1 /* Any copyright is dedicated to the Public Domain. |
|
2 http://creativecommons.org/publicdomain/zero/1.0/ |
|
3 */ |
|
4 /* This testcase triggers two telemetry pings. |
|
5 * |
|
6 * Telemetry code keeps histograms of past telemetry pings. The first |
|
7 * ping populates these histograms. One of those histograms is then |
|
8 * checked in the second request. |
|
9 */ |
|
10 |
|
11 const Cc = Components.classes; |
|
12 const Ci = Components.interfaces; |
|
13 const Cu = Components.utils; |
|
14 const Cr = Components.results; |
|
15 |
|
16 Cu.import("resource://testing-common/httpd.js", this); |
|
17 Cu.import("resource://gre/modules/Services.jsm"); |
|
18 Cu.import("resource://gre/modules/LightweightThemeManager.jsm", this); |
|
19 Cu.import("resource://gre/modules/XPCOMUtils.jsm", this); |
|
20 Cu.import("resource://gre/modules/TelemetryPing.jsm", this); |
|
21 Cu.import("resource://gre/modules/TelemetryFile.jsm", this); |
|
22 Cu.import("resource://gre/modules/Task.jsm", this); |
|
23 Cu.import("resource://gre/modules/Promise.jsm", this); |
|
24 |
|
25 const IGNORE_HISTOGRAM = "test::ignore_me"; |
|
26 const IGNORE_HISTOGRAM_TO_CLONE = "MEMORY_HEAP_ALLOCATED"; |
|
27 const IGNORE_CLONED_HISTOGRAM = "test::ignore_me_also"; |
|
28 const ADDON_NAME = "Telemetry test addon"; |
|
29 const ADDON_HISTOGRAM = "addon-histogram"; |
|
30 // Add some unicode characters here to ensure that sending them works correctly. |
|
31 const FLASH_VERSION = "\u201c1.1.1.1\u201d"; |
|
32 const SHUTDOWN_TIME = 10000; |
|
33 const FAILED_PROFILE_LOCK_ATTEMPTS = 2; |
|
34 |
|
35 // Constants from prio.h for nsIFileOutputStream.init |
|
36 const PR_WRONLY = 0x2; |
|
37 const PR_CREATE_FILE = 0x8; |
|
38 const PR_TRUNCATE = 0x20; |
|
39 const RW_OWNER = 0600; |
|
40 |
|
41 const NUMBER_OF_THREADS_TO_LAUNCH = 30; |
|
42 let gNumberOfThreadsLaunched = 0; |
|
43 |
|
44 const Telemetry = Cc["@mozilla.org/base/telemetry;1"].getService(Ci.nsITelemetry); |
|
45 |
|
46 let gHttpServer = new HttpServer(); |
|
47 let gServerStarted = false; |
|
48 let gRequestIterator = null; |
|
49 |
|
50 function sendPing () { |
|
51 TelemetryPing.gatherStartup(); |
|
52 if (gServerStarted) { |
|
53 return TelemetryPing.testPing("http://localhost:" + gHttpServer.identity.primaryPort); |
|
54 } else { |
|
55 return TelemetryPing.testPing("http://doesnotexist"); |
|
56 } |
|
57 } |
|
58 |
|
59 function wrapWithExceptionHandler(f) { |
|
60 function wrapper(...args) { |
|
61 try { |
|
62 f(...args); |
|
63 } catch (ex if typeof(ex) == 'object') { |
|
64 dump("Caught exception: " + ex.message + "\n"); |
|
65 dump(ex.stack); |
|
66 do_test_finished(); |
|
67 } |
|
68 } |
|
69 return wrapper; |
|
70 } |
|
71 |
|
72 function registerPingHandler(handler) { |
|
73 gHttpServer.registerPrefixHandler("/submit/telemetry/", |
|
74 wrapWithExceptionHandler(handler)); |
|
75 } |
|
76 |
|
77 function setupTestData() { |
|
78 Telemetry.newHistogram(IGNORE_HISTOGRAM, "never", 1, 2, 3, Telemetry.HISTOGRAM_BOOLEAN); |
|
79 Telemetry.histogramFrom(IGNORE_CLONED_HISTOGRAM, IGNORE_HISTOGRAM_TO_CLONE); |
|
80 Services.startup.interrupted = true; |
|
81 Telemetry.registerAddonHistogram(ADDON_NAME, ADDON_HISTOGRAM, 1, 5, 6, |
|
82 Telemetry.HISTOGRAM_LINEAR); |
|
83 h1 = Telemetry.getAddonHistogram(ADDON_NAME, ADDON_HISTOGRAM); |
|
84 h1.add(1); |
|
85 } |
|
86 |
|
87 function getSavedHistogramsFile(basename) { |
|
88 let tmpDir = Services.dirsvc.get("ProfD", Ci.nsIFile); |
|
89 let histogramsFile = tmpDir.clone(); |
|
90 histogramsFile.append(basename); |
|
91 if (histogramsFile.exists()) { |
|
92 histogramsFile.remove(true); |
|
93 } |
|
94 do_register_cleanup(function () { |
|
95 try { |
|
96 histogramsFile.remove(true); |
|
97 } catch (e) { |
|
98 } |
|
99 }); |
|
100 return histogramsFile; |
|
101 } |
|
102 |
|
103 function decodeRequestPayload(request) { |
|
104 let s = request.bodyInputStream; |
|
105 let payload = null; |
|
106 let decoder = Cc["@mozilla.org/dom/json;1"].createInstance(Ci.nsIJSON) |
|
107 |
|
108 if (request.getHeader("content-encoding") == "gzip") { |
|
109 let observer = { |
|
110 buffer: "", |
|
111 onStreamComplete: function(loader, context, status, length, result) { |
|
112 this.buffer = String.fromCharCode.apply(this, result); |
|
113 } |
|
114 }; |
|
115 |
|
116 let scs = Cc["@mozilla.org/streamConverters;1"] |
|
117 .getService(Ci.nsIStreamConverterService); |
|
118 let listener = Cc["@mozilla.org/network/stream-loader;1"] |
|
119 .createInstance(Ci.nsIStreamLoader); |
|
120 listener.init(observer); |
|
121 let converter = scs.asyncConvertData("gzip", "uncompressed", |
|
122 listener, null); |
|
123 converter.onStartRequest(null, null); |
|
124 converter.onDataAvailable(null, null, s, 0, s.available()); |
|
125 converter.onStopRequest(null, null, null); |
|
126 let unicodeConverter = Cc["@mozilla.org/intl/scriptableunicodeconverter"] |
|
127 .createInstance(Ci.nsIScriptableUnicodeConverter); |
|
128 unicodeConverter.charset = "UTF-8"; |
|
129 let utf8string = unicodeConverter.ConvertToUnicode(observer.buffer); |
|
130 utf8string += unicodeConverter.Finish(); |
|
131 payload = decoder.decode(utf8string); |
|
132 } else { |
|
133 payload = decoder.decodeFromStream(s, s.available()); |
|
134 } |
|
135 |
|
136 return payload; |
|
137 } |
|
138 |
|
139 function checkPayloadInfo(payload, reason) { |
|
140 // get rid of the non-deterministic field |
|
141 const expected_info = { |
|
142 OS: "XPCShell", |
|
143 appID: "xpcshell@tests.mozilla.org", |
|
144 appVersion: "1", |
|
145 appName: "XPCShell", |
|
146 appBuildID: "2007010101", |
|
147 platformBuildID: "2007010101", |
|
148 flashVersion: FLASH_VERSION |
|
149 }; |
|
150 |
|
151 for (let f in expected_info) { |
|
152 do_check_eq(payload.info[f], expected_info[f]); |
|
153 } |
|
154 |
|
155 do_check_eq(payload.info.reason, reason); |
|
156 do_check_true("appUpdateChannel" in payload.info); |
|
157 do_check_true("locale" in payload.info); |
|
158 do_check_true("revision" in payload.info); |
|
159 do_check_true(payload.info.revision.startsWith("http")); |
|
160 |
|
161 try { |
|
162 // If we've not got nsIGfxInfoDebug, then this will throw and stop us doing |
|
163 // this test. |
|
164 let gfxInfo = Cc["@mozilla.org/gfx/info;1"].getService(Ci.nsIGfxInfoDebug); |
|
165 let isWindows = ("@mozilla.org/windows-registry-key;1" in Components.classes); |
|
166 let isOSX = ("nsILocalFileMac" in Components.interfaces); |
|
167 |
|
168 if (isWindows || isOSX) { |
|
169 do_check_true("adapterVendorID" in payload.info); |
|
170 do_check_true("adapterDeviceID" in payload.info); |
|
171 } |
|
172 } |
|
173 catch (x) { |
|
174 } |
|
175 } |
|
176 |
|
177 function checkPayload(request, reason, successfulPings) { |
|
178 let payload = decodeRequestPayload(request); |
|
179 // Take off ["","submit","telemetry"]. |
|
180 let pathComponents = request.path.split("/").slice(3); |
|
181 |
|
182 checkPayloadInfo(payload, reason); |
|
183 do_check_eq(reason, pathComponents[1]); |
|
184 do_check_eq(request.getHeader("content-type"), "application/json; charset=UTF-8"); |
|
185 do_check_true(payload.simpleMeasurements.uptime >= 0); |
|
186 do_check_true(payload.simpleMeasurements.startupInterrupted === 1); |
|
187 do_check_eq(payload.simpleMeasurements.shutdownDuration, SHUTDOWN_TIME); |
|
188 do_check_eq(payload.simpleMeasurements.savedPings, 1); |
|
189 do_check_true("maximalNumberOfConcurrentThreads" in payload.simpleMeasurements); |
|
190 do_check_true(payload.simpleMeasurements.maximalNumberOfConcurrentThreads >= gNumberOfThreadsLaunched); |
|
191 |
|
192 do_check_eq(payload.simpleMeasurements.failedProfileLockCount, |
|
193 FAILED_PROFILE_LOCK_ATTEMPTS); |
|
194 let profileDirectory = Services.dirsvc.get("ProfD", Ci.nsIFile); |
|
195 let failedProfileLocksFile = profileDirectory.clone(); |
|
196 failedProfileLocksFile.append("Telemetry.FailedProfileLocks.txt"); |
|
197 do_check_true(!failedProfileLocksFile.exists()); |
|
198 |
|
199 |
|
200 let isWindows = ("@mozilla.org/windows-registry-key;1" in Components.classes); |
|
201 if (isWindows) { |
|
202 do_check_true(payload.simpleMeasurements.startupSessionRestoreReadBytes > 0); |
|
203 do_check_true(payload.simpleMeasurements.startupSessionRestoreWriteBytes > 0); |
|
204 } |
|
205 |
|
206 const TELEMETRY_PING = "TELEMETRY_PING"; |
|
207 const TELEMETRY_SUCCESS = "TELEMETRY_SUCCESS"; |
|
208 const TELEMETRY_TEST_FLAG = "TELEMETRY_TEST_FLAG"; |
|
209 const READ_SAVED_PING_SUCCESS = "READ_SAVED_PING_SUCCESS"; |
|
210 do_check_true(TELEMETRY_PING in payload.histograms); |
|
211 do_check_true(READ_SAVED_PING_SUCCESS in payload.histograms); |
|
212 let rh = Telemetry.registeredHistograms([]); |
|
213 for (let name of rh) { |
|
214 if (/SQLITE/.test(name) && name in payload.histograms) { |
|
215 do_check_true(("STARTUP_" + name) in payload.histograms); |
|
216 } |
|
217 } |
|
218 do_check_false(IGNORE_HISTOGRAM in payload.histograms); |
|
219 do_check_false(IGNORE_CLONED_HISTOGRAM in payload.histograms); |
|
220 |
|
221 // Flag histograms should automagically spring to life. |
|
222 const expected_flag = { |
|
223 range: [1, 2], |
|
224 bucket_count: 3, |
|
225 histogram_type: 3, |
|
226 values: {0:1, 1:0}, |
|
227 sum: 0, |
|
228 sum_squares_lo: 0, |
|
229 sum_squares_hi: 0 |
|
230 }; |
|
231 let flag = payload.histograms[TELEMETRY_TEST_FLAG]; |
|
232 do_check_eq(uneval(flag), uneval(expected_flag)); |
|
233 |
|
234 // There should be one successful report from the previous telemetry ping. |
|
235 const expected_tc = { |
|
236 range: [1, 2], |
|
237 bucket_count: 3, |
|
238 histogram_type: 2, |
|
239 values: {0:1, 1:successfulPings, 2:0}, |
|
240 sum: successfulPings, |
|
241 sum_squares_lo: successfulPings, |
|
242 sum_squares_hi: 0 |
|
243 }; |
|
244 let tc = payload.histograms[TELEMETRY_SUCCESS]; |
|
245 do_check_eq(uneval(tc), uneval(expected_tc)); |
|
246 |
|
247 let h = payload.histograms[READ_SAVED_PING_SUCCESS]; |
|
248 do_check_eq(h.values[0], 1); |
|
249 |
|
250 // The ping should include data from memory reporters. We can't check that |
|
251 // this data is correct, because we can't control the values returned by the |
|
252 // memory reporters. But we can at least check that the data is there. |
|
253 // |
|
254 // It's important to check for the presence of reporters with a mix of units, |
|
255 // because TelemetryPing has separate logic for each one. But we can't |
|
256 // currently check UNITS_COUNT_CUMULATIVE or UNITS_PERCENTAGE because |
|
257 // Telemetry doesn't touch a memory reporter with these units that's |
|
258 // available on all platforms. |
|
259 |
|
260 do_check_true('MEMORY_JS_GC_HEAP' in payload.histograms); // UNITS_BYTES |
|
261 do_check_true('MEMORY_JS_COMPARTMENTS_SYSTEM' in payload.histograms); // UNITS_COUNT |
|
262 |
|
263 // We should have included addon histograms. |
|
264 do_check_true("addonHistograms" in payload); |
|
265 do_check_true(ADDON_NAME in payload.addonHistograms); |
|
266 do_check_true(ADDON_HISTOGRAM in payload.addonHistograms[ADDON_NAME]); |
|
267 |
|
268 do_check_true(("mainThread" in payload.slowSQL) && |
|
269 ("otherThreads" in payload.slowSQL)); |
|
270 } |
|
271 |
|
272 function dummyTheme(id) { |
|
273 return { |
|
274 id: id, |
|
275 name: Math.random().toString(), |
|
276 headerURL: "http://lwttest.invalid/a.png", |
|
277 footerURL: "http://lwttest.invalid/b.png", |
|
278 textcolor: Math.random().toString(), |
|
279 accentcolor: Math.random().toString() |
|
280 }; |
|
281 } |
|
282 |
|
283 // A fake plugin host for testing flash version telemetry |
|
284 let PluginHost = { |
|
285 getPluginTags: function(countRef) { |
|
286 let plugins = [{name: "Shockwave Flash", version: FLASH_VERSION}]; |
|
287 countRef.value = plugins.length; |
|
288 return plugins; |
|
289 }, |
|
290 |
|
291 QueryInterface: function(iid) { |
|
292 if (iid.equals(Ci.nsIPluginHost) |
|
293 || iid.equals(Ci.nsISupports)) |
|
294 return this; |
|
295 |
|
296 throw Components.results.NS_ERROR_NO_INTERFACE; |
|
297 } |
|
298 } |
|
299 |
|
300 let PluginHostFactory = { |
|
301 createInstance: function (outer, iid) { |
|
302 if (outer != null) |
|
303 throw Components.results.NS_ERROR_NO_AGGREGATION; |
|
304 return PluginHost.QueryInterface(iid); |
|
305 } |
|
306 }; |
|
307 |
|
308 const PLUGINHOST_CONTRACTID = "@mozilla.org/plugin/host;1"; |
|
309 const PLUGINHOST_CID = Components.ID("{2329e6ea-1f15-4cbe-9ded-6e98e842de0e}"); |
|
310 |
|
311 function registerFakePluginHost() { |
|
312 let registrar = Components.manager.QueryInterface(Ci.nsIComponentRegistrar); |
|
313 registrar.registerFactory(PLUGINHOST_CID, "Fake Plugin Host", |
|
314 PLUGINHOST_CONTRACTID, PluginHostFactory); |
|
315 } |
|
316 |
|
317 function writeStringToFile(file, contents) { |
|
318 let ostream = Cc["@mozilla.org/network/safe-file-output-stream;1"] |
|
319 .createInstance(Ci.nsIFileOutputStream); |
|
320 ostream.init(file, PR_WRONLY | PR_CREATE_FILE | PR_TRUNCATE, |
|
321 RW_OWNER, ostream.DEFER_OPEN); |
|
322 ostream.write(contents, contents.length); |
|
323 ostream.QueryInterface(Ci.nsISafeOutputStream).finish(); |
|
324 ostream.close(); |
|
325 } |
|
326 |
|
327 function write_fake_shutdown_file() { |
|
328 let profileDirectory = Services.dirsvc.get("ProfD", Ci.nsIFile); |
|
329 let file = profileDirectory.clone(); |
|
330 file.append("Telemetry.ShutdownTime.txt"); |
|
331 let contents = "" + SHUTDOWN_TIME; |
|
332 writeStringToFile(file, contents); |
|
333 } |
|
334 |
|
335 function write_fake_failedprofilelocks_file() { |
|
336 let profileDirectory = Services.dirsvc.get("ProfD", Ci.nsIFile); |
|
337 let file = profileDirectory.clone(); |
|
338 file.append("Telemetry.FailedProfileLocks.txt"); |
|
339 let contents = "" + FAILED_PROFILE_LOCK_ATTEMPTS; |
|
340 writeStringToFile(file, contents); |
|
341 } |
|
342 |
|
343 function run_test() { |
|
344 do_test_pending(); |
|
345 try { |
|
346 let gfxInfo = Cc["@mozilla.org/gfx/info;1"].getService(Ci.nsIGfxInfoDebug); |
|
347 gfxInfo.spoofVendorID("0xabcd"); |
|
348 gfxInfo.spoofDeviceID("0x1234"); |
|
349 } catch (x) { |
|
350 // If we can't test gfxInfo, that's fine, we'll note it later. |
|
351 } |
|
352 |
|
353 // Addon manager needs a profile directory |
|
354 do_get_profile(); |
|
355 createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "1", "1.9.2"); |
|
356 |
|
357 // Make it look like we've previously failed to lock a profile a couple times. |
|
358 write_fake_failedprofilelocks_file(); |
|
359 |
|
360 // Make it look like we've shutdown before. |
|
361 write_fake_shutdown_file(); |
|
362 |
|
363 let currentMaxNumberOfThreads = Telemetry.maximalNumberOfConcurrentThreads; |
|
364 do_check_true(currentMaxNumberOfThreads > 0); |
|
365 |
|
366 // Try to augment the maximal number of threads currently launched |
|
367 let threads = []; |
|
368 try { |
|
369 for (let i = 0; i < currentMaxNumberOfThreads + 10; ++i) { |
|
370 threads.push(Services.tm.newThread(0)); |
|
371 } |
|
372 } catch (ex) { |
|
373 // If memory is too low, it is possible that not all threads will be launched. |
|
374 } |
|
375 gNumberOfThreadsLaunched = threads.length; |
|
376 |
|
377 do_check_true(Telemetry.maximalNumberOfConcurrentThreads >= gNumberOfThreadsLaunched); |
|
378 |
|
379 do_register_cleanup(function() { |
|
380 threads.forEach(function(thread) { |
|
381 thread.shutdown(); |
|
382 }); |
|
383 }); |
|
384 |
|
385 Telemetry.asyncFetchTelemetryData(wrapWithExceptionHandler(actualTest)); |
|
386 } |
|
387 |
|
388 function actualTest() { |
|
389 // try to make LightweightThemeManager do stuff |
|
390 let gInternalManager = Cc["@mozilla.org/addons/integration;1"] |
|
391 .getService(Ci.nsIObserver) |
|
392 .QueryInterface(Ci.nsITimerCallback); |
|
393 |
|
394 gInternalManager.observe(null, "addons-startup", null); |
|
395 LightweightThemeManager.currentTheme = dummyTheme("1234"); |
|
396 |
|
397 // fake plugin host for consistent flash version data |
|
398 registerFakePluginHost(); |
|
399 |
|
400 run_next_test(); |
|
401 } |
|
402 |
|
403 // Ensure that not overwriting an existing file fails silently |
|
404 add_task(function* test_overwritePing() { |
|
405 let ping = {slug: "foo"} |
|
406 yield TelemetryFile.savePing(ping, true); |
|
407 yield TelemetryFile.savePing(ping, false); |
|
408 yield TelemetryFile.cleanupPingFile(ping); |
|
409 }); |
|
410 |
|
411 // Ensures that expired histograms are not part of the payload. |
|
412 add_task(function* test_expiredHistogram() { |
|
413 let histogram_id = "FOOBAR"; |
|
414 let dummy = Telemetry.newHistogram(histogram_id, "30", 1, 2, 3, Telemetry.HISTOGRAM_EXPONENTIAL); |
|
415 |
|
416 dummy.add(1); |
|
417 |
|
418 do_check_eq(TelemetryPing.getPayload()["histograms"][histogram_id], undefined); |
|
419 do_check_eq(TelemetryPing.getPayload()["histograms"]["TELEMETRY_TEST_EXPIRED"], undefined); |
|
420 }); |
|
421 |
|
422 // Checks that an invalid histogram file is deleted if TelemetryFile fails to parse it. |
|
423 add_task(function* test_runInvalidJSON() { |
|
424 let histogramsFile = getSavedHistogramsFile("invalid-histograms.dat"); |
|
425 |
|
426 writeStringToFile(histogramsFile, "this.is.invalid.JSON"); |
|
427 do_check_true(histogramsFile.exists()); |
|
428 |
|
429 yield TelemetryPing.testLoadHistograms(histogramsFile); |
|
430 do_check_false(histogramsFile.exists()); |
|
431 }); |
|
432 |
|
433 // Sends a ping to a non existing server. |
|
434 add_task(function* test_noServerPing() { |
|
435 yield sendPing(); |
|
436 }); |
|
437 |
|
438 // Checks that a sent ping is correctly received by a dummy http server. |
|
439 add_task(function* test_simplePing() { |
|
440 gHttpServer.start(-1); |
|
441 gServerStarted = true; |
|
442 gRequestIterator = Iterator(new Request()); |
|
443 |
|
444 yield sendPing(); |
|
445 decodeRequestPayload(yield gRequestIterator.next()); |
|
446 }); |
|
447 |
|
448 // Saves the current session histograms, reloads them, perfoms a ping |
|
449 // and checks that the dummy http server received both the previously |
|
450 // saved histograms and the new ones. |
|
451 add_task(function* test_saveLoadPing() { |
|
452 let histogramsFile = getSavedHistogramsFile("saved-histograms.dat"); |
|
453 |
|
454 setupTestData(); |
|
455 yield TelemetryPing.testSaveHistograms(histogramsFile); |
|
456 yield TelemetryPing.testLoadHistograms(histogramsFile); |
|
457 yield sendPing(); |
|
458 checkPayload((yield gRequestIterator.next()), "test-ping", 1); |
|
459 checkPayload((yield gRequestIterator.next()), "saved-session", 1); |
|
460 }); |
|
461 |
|
462 // Checks that an expired histogram file is deleted when loaded. |
|
463 add_task(function* test_runOldPingFile() { |
|
464 let histogramsFile = getSavedHistogramsFile("old-histograms.dat"); |
|
465 |
|
466 yield TelemetryPing.testSaveHistograms(histogramsFile); |
|
467 do_check_true(histogramsFile.exists()); |
|
468 let mtime = histogramsFile.lastModifiedTime; |
|
469 histogramsFile.lastModifiedTime = mtime - (14 * 24 * 60 * 60 * 1000 + 60000); // 14 days, 1m |
|
470 |
|
471 yield TelemetryPing.testLoadHistograms(histogramsFile); |
|
472 do_check_false(histogramsFile.exists()); |
|
473 }); |
|
474 |
|
475 add_task(function* stopServer(){ |
|
476 gHttpServer.stop(do_test_finished); |
|
477 }); |
|
478 |
|
479 // An iterable sequence of http requests |
|
480 function Request() { |
|
481 let defers = []; |
|
482 let current = 0; |
|
483 |
|
484 function RequestIterator() {} |
|
485 |
|
486 // Returns a promise that resolves to the next http request |
|
487 RequestIterator.prototype.next = function() { |
|
488 let deferred = defers[current++]; |
|
489 return deferred.promise; |
|
490 } |
|
491 |
|
492 this.__iterator__ = function(){ |
|
493 return new RequestIterator(); |
|
494 } |
|
495 |
|
496 registerPingHandler((request, response) => { |
|
497 let deferred = defers[defers.length - 1]; |
|
498 defers.push(Promise.defer()); |
|
499 deferred.resolve(request); |
|
500 }); |
|
501 |
|
502 defers.push(Promise.defer()); |
|
503 } |