From b6ca952eeb4670c3e91e97f4ffce274af6f42ca2 Mon Sep 17 00:00:00 2001 From: David Aurelio Date: Mon, 27 Feb 2017 10:44:28 -0800 Subject: [PATCH] Symbolicate stack traces off the main process Summary: Moves stack trace symbolication to a worker process. The worker process is spawned laziliy, and is treated as an exclusive resource (requests are queued). This helps keeping the server process responsive when symbolicating. Reviewed By: cpojer Differential Revision: D4602722 fbshipit-source-id: 5da97e53afd9a1ab981c5ba4b02a7d1d869dee71 --- package.json | 2 + packager/package.json | 4 +- packager/src/Server/__tests__/Server-test.js | 83 ++++----- packager/src/Server/index.js | 71 ++++---- .../symbolicate/__tests__/symbolicate-test.js | 106 ++++++++++++ .../Server/symbolicate/__tests__/util-test.js | 157 ++++++++++++++++++ .../symbolicate/__tests__/worker-test.js | 111 +++++++++++++ packager/src/Server/symbolicate/package.json | 1 + .../src/Server/symbolicate/symbolicate.js | 76 +++++++++ packager/src/Server/symbolicate/util.js | 86 ++++++++++ packager/src/Server/symbolicate/worker.js | 77 +++++++++ 11 files changed, 684 insertions(+), 90 deletions(-) create mode 100644 packager/src/Server/symbolicate/__tests__/symbolicate-test.js create mode 100644 packager/src/Server/symbolicate/__tests__/util-test.js create mode 100644 packager/src/Server/symbolicate/__tests__/worker-test.js create mode 100644 packager/src/Server/symbolicate/package.json create mode 100644 packager/src/Server/symbolicate/symbolicate.js create mode 100644 packager/src/Server/symbolicate/util.js create mode 100644 packager/src/Server/symbolicate/worker.js diff --git a/package.json b/package.json index 63896e740..5e9f378d2 100644 --- a/package.json +++ b/package.json @@ -153,6 +153,7 @@ "bser": "^1.0.2", "chalk": "^1.1.1", "commander": "^2.9.0", + "concat-stream": "^1.6.0", "connect": "^2.8.3", "core-js": "^2.2.2", "debug": "^2.2.0", @@ -207,6 +208,7 @@ "ws": "^1.1.0", "xcode": "^0.8.9", "xmldoc": "^0.4.0", + "xpipe": "^1.0.5", "yargs": "^6.4.0" }, "devDependencies": { diff --git a/packager/package.json b/packager/package.json index 94ab30c0c..2ea83333e 100644 --- a/packager/package.json +++ b/packager/package.json @@ -17,6 +17,7 @@ "babel-register": "^6.18.0", "babylon": "^6.14.1", "chalk": "^1.1.1", + "concat-stream": "^1.6.0", "core-js": "^2.2.2", "debug": "^2.2.0", "denodeify": "^1.2.1", @@ -38,6 +39,7 @@ "throat": "^3.0.0", "uglify-js": "^2.6.2", "worker-farm": "^1.3.1", - "write-file-atomic": "^1.2.0" + "write-file-atomic": "^1.2.0", + "xpipe": "^1.0.5" } } diff --git a/packager/src/Server/__tests__/Server-test.js b/packager/src/Server/__tests__/Server-test.js index 1cb3db2d9..40dbbf5ce 100644 --- a/packager/src/Server/__tests__/Server-test.js +++ b/packager/src/Server/__tests__/Server-test.js @@ -10,11 +10,14 @@ jest.disableAutomock(); -jest.setMock('worker-farm', function() { return () => {}; }) - .setMock('timers', { setImmediate: (fn) => setTimeout(fn, 0) }) - .setMock('uglify-js') - .setMock('crypto') - .setMock('source-map', { SourceMapConsumer: function(fn) {}}) +jest.mock('worker-farm', () => () => () => {}) + .mock('timers', () => ({ setImmediate: (fn) => setTimeout(fn, 0) })) + .mock('uglify-js') + .mock('crypto') + .mock( + '../symbolicate', + () => ({createWorker: jest.fn().mockReturnValue(jest.fn())}), + ) .mock('../../Bundler') .mock('../../AssetServer') .mock('../../lib/declareOpts') @@ -23,14 +26,14 @@ jest.setMock('worker-farm', function() { return () => {}; }) .mock('../../lib/GlobalTransformCache'); describe('processRequest', () => { - let SourceMapConsumer, Bundler, Server, AssetServer, Promise; + let Bundler, Server, AssetServer, Promise, symbolicate; beforeEach(() => { jest.resetModules(); - SourceMapConsumer = require('source-map').SourceMapConsumer; Bundler = require('../../Bundler'); Server = require('../'); AssetServer = require('../../AssetServer'); Promise = require('promise'); + symbolicate = require('../symbolicate'); }); let server; @@ -69,7 +72,7 @@ describe('processRequest', () => { Promise.resolve({ getModules: () => [], getSource: () => 'this is the source', - getSourceMap: () => {}, + getSourceMap: () => ({version: 3}), getSourceMapString: () => 'this is the source map', getEtag: () => 'this is an etag', })); @@ -464,60 +467,38 @@ describe('processRequest', () => { }); describe('/symbolicate endpoint', () => { + let symbolicationWorker; + beforeEach(() => { + symbolicationWorker = symbolicate.createWorker(); + symbolicationWorker.mockReset(); + }); + it('should symbolicate given stack trace', () => { - const body = JSON.stringify({stack: [{ + const inputStack = [{ file: 'http://foo.bundle?platform=ios', lineNumber: 2100, column: 44, customPropShouldBeLeftUnchanged: 'foo', - }]}); + }]; + const outputStack = [{ + source: 'foo.js', + line: 21, + column: 4, + }]; + const body = JSON.stringify({stack: inputStack}); - SourceMapConsumer.prototype.originalPositionFor = jest.fn((frame) => { - expect(frame.line).toEqual(2100); - expect(frame.column).toEqual(44); - return { - source: 'foo.js', - line: 21, - column: 4, - }; + expect.assertions(2); + symbolicationWorker.mockImplementation(stack => { + expect(stack).toEqual(inputStack); + return outputStack; }); return makeRequest( requestHandler, '/symbolicate', - { rawBody: body } - ).then(response => { - expect(JSON.parse(response.body)).toEqual({ - stack: [{ - file: 'foo.js', - lineNumber: 21, - column: 4, - customPropShouldBeLeftUnchanged: 'foo', - }] - }); - }); - }); - - it('ignores `/debuggerWorker.js` stack frames', () => { - const body = JSON.stringify({stack: [{ - file: 'http://localhost:8081/debuggerWorker.js', - lineNumber: 123, - column: 456, - }]}); - - return makeRequest( - requestHandler, - '/symbolicate', - { rawBody: body } - ).then(response => { - expect(JSON.parse(response.body)).toEqual({ - stack: [{ - file: 'http://localhost:8081/debuggerWorker.js', - lineNumber: 123, - column: 456, - }] - }); - }); + {rawBody: body}, + ).then(response => + expect(JSON.parse(response.body)).toEqual({stack: outputStack})); }); }); diff --git a/packager/src/Server/index.js b/packager/src/Server/index.js index d6b9f3d21..f661403ac 100644 --- a/packager/src/Server/index.js +++ b/packager/src/Server/index.js @@ -15,12 +15,12 @@ const AssetServer = require('../AssetServer'); const getPlatformExtension = require('../node-haste').getPlatformExtension; const Bundler = require('../Bundler'); const MultipartResponse = require('./MultipartResponse'); -const SourceMapConsumer = require('source-map').SourceMapConsumer; const declareOpts = require('../lib/declareOpts'); const defaults = require('../../defaults'); const mime = require('mime-types'); const path = require('path'); +const symbolicate = require('./symbolicate'); const terminal = require('../lib/terminal'); const url = require('url'); @@ -34,6 +34,7 @@ import type Bundle from '../Bundler/Bundle'; import type {Reporter} from '../lib/reporting'; import type {GetTransformOptions} from '../Bundler'; import type GlobalTransformCache from '../lib/GlobalTransformCache'; +import type {SourceMap, Symbolicate} from './symbolicate'; const { createActionStartEntry, @@ -201,6 +202,7 @@ class Server { _debouncedFileChangeHandler: (filePath: string) => mixed; _hmrFileChangeListener: (type: string, filePath: string) => mixed; _reporter: Reporter; + _symbolicateInWorker: Symbolicate; constructor(options: Options) { this._opts = { @@ -281,6 +283,8 @@ class Server { } this._informChangeWatchers(); }, 50); + + this._symbolicateInWorker = symbolicate.createWorker(); } end(): mixed { @@ -802,45 +806,27 @@ class Server { // In case of multiple bundles / HMR, some stack frames can have // different URLs from others - const urlIndexes = {}; - const uniqueUrls = []; + const urls = new Set(); stack.forEach(frame => { const sourceUrl = frame.file; // Skip `/debuggerWorker.js` which drives remote debugging because it // does not need to symbolication. // Skip anything except http(s), because there is no support for that yet - if (!urlIndexes.hasOwnProperty(sourceUrl) && + if (!urls.has(sourceUrl) && !sourceUrl.endsWith('/debuggerWorker.js') && sourceUrl.startsWith('http')) { - urlIndexes[sourceUrl] = uniqueUrls.length; - uniqueUrls.push(sourceUrl); + urls.add(sourceUrl); } }); - const sourceMaps = uniqueUrls.map( - sourceUrl => this._sourceMapForURL(sourceUrl) - ); - return Promise.all(sourceMaps).then(consumers => { - return stack.map(frame => { - const sourceUrl = frame.file; - if (!urlIndexes.hasOwnProperty(sourceUrl)) { - return frame; - } - const idx = urlIndexes[sourceUrl]; - const consumer = consumers[idx]; - const original = consumer.originalPositionFor({ - line: frame.lineNumber, - column: frame.column, - }); - if (!original) { - return frame; - } - return Object.assign({}, frame, { - file: original.source, - lineNumber: original.line, - column: original.column, - }); - }); + const mapPromises = + Array.from(urls.values()).map(this._sourceMapForURL, this); + + debug('Getting source maps for symbolication'); + return Promise.all(mapPromises).then(maps => { + debug('Sending stacks and maps to symbolication worker'); + const urlsToMaps = zip(urls.values(), maps); + return this._symbolicateInWorker(stack, urlsToMaps); }); }).then( stack => { @@ -858,16 +844,13 @@ class Server { ); } - _sourceMapForURL(reqUrl: string): Promise { + _sourceMapForURL(reqUrl: string): Promise { const options = this._getOptionsFromUrl(reqUrl); const building = this._useCachedOrUpdateOrCreateBundle(options); - return building.then(p => { - const sourceMap = p.getSourceMap({ - minify: options.minify, - dev: options.dev, - }); - return new SourceMapConsumer(sourceMap); - }); + return building.then(p => p.getSourceMap({ + minify: options.minify, + dev: options.dev, + })); } _handleError(res: ServerResponse, bundleID: string, error: { @@ -990,4 +973,16 @@ function contentsEqual(array: Array, set: Set): boolean { return array.length === set.size && array.every(set.has, set); } +function* zip(xs: Iterable, ys: Iterable): Iterable<[X, Y]> { + //$FlowIssue #9324959 + const ysIter: Iterator = ys[Symbol.iterator](); + for (const x of xs) { + const y = ysIter.next(); + if (y.done) { + return; + } + yield [x, y.value]; + } +} + module.exports = Server; diff --git a/packager/src/Server/symbolicate/__tests__/symbolicate-test.js b/packager/src/Server/symbolicate/__tests__/symbolicate-test.js new file mode 100644 index 000000000..d5dcc72a7 --- /dev/null +++ b/packager/src/Server/symbolicate/__tests__/symbolicate-test.js @@ -0,0 +1,106 @@ +/** + * Copyright (c) 2015-present, Facebook, Inc. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. An additional grant + * of patent rights can be found in the PATENTS file in the same directory. + */ +'use strict'; + +jest.disableAutomock(); +jest.mock('child_process'); +jest.mock('net'); + +const EventEmitter = require('events'); +const {Readable} = require('stream'); +const {createWorker} = require('../'); + +let childProcess, socketResponse, socket, worker; + +beforeEach(() => { + childProcess = Object.assign(new EventEmitter(), {send: jest.fn()}); + require('child_process').fork.mockReturnValueOnce(childProcess); + setupCommunication(); + + socketResponse = '{"error": "no fake socket response set"}'; + socket = Object.assign(new Readable(), { + _read() { + this.push(socketResponse); + this.push(null); + }, + end: jest.fn(), + setEncoding: jest.fn(), + }); + require('net').createConnection.mockImplementation(() => socket); + + worker = createWorker(); +}); + +it('sends a socket path to the child process', () => { + socketResponse = '{}'; + return worker([], fakeSourceMaps()) + .then(() => expect(childProcess.send).toBeCalledWith(expect.any(String))); +}); + +it('fails if the child process emits an error', () => { + const error = new Error('Expected error'); + childProcess.send.mockImplementation(() => + childProcess.emit('error', error)); + + expect.assertions(1); + return worker([], fakeSourceMaps()) + .catch(e => expect(e).toBe(error)); +}); + +it('fails if the socket connection emits an error', () => { + const error = new Error('Expected error'); + socket._read = () => socket.emit('error', error); + + expect.assertions(1); + return worker([], fakeSourceMaps()) + .catch(e => expect(e).toBe(error)); +}); + +it('sends the passed in stack and maps over the socket', () => { + socketResponse = '{}'; + const stack = ['the', 'stack']; + return worker(stack, fakeSourceMaps()) + .then(() => + expect(socket.end).toBeCalledWith(JSON.stringify({ + maps: Array.from(fakeSourceMaps()), + stack, + }))); +}); + +it('resolves to the `result` property of the message returned over the socket', () => { + socketResponse = '{"result": {"the": "result"}}'; + return worker([], fakeSourceMaps()) + .then(response => expect(response).toEqual({the: 'result'})); +}); + +it('rejects with the `error` property of the message returned over the socket', () => { + socketResponse = '{"error": "the error message"}'; + + expect.assertions(1); + return worker([], fakeSourceMaps()) + .catch(error => expect(error).toEqual(new Error('the error message'))); +}); + +it('rejects if the socket response cannot be parsed as JSON', () => { + socketResponse = '{'; + + expect.assertions(1); + return worker([], fakeSourceMaps()) + .catch(error => expect(error).toBeInstanceOf(SyntaxError)); +}); + +function setupCommunication() { + childProcess.send.mockImplementation(() => + process.nextTick(() => childProcess.emit('message'))); +} + +function* fakeSourceMaps() { + yield [1, {}]; + yield [2, {}]; +} diff --git a/packager/src/Server/symbolicate/__tests__/util-test.js b/packager/src/Server/symbolicate/__tests__/util-test.js new file mode 100644 index 000000000..1311b920b --- /dev/null +++ b/packager/src/Server/symbolicate/__tests__/util-test.js @@ -0,0 +1,157 @@ +/** + * Copyright (c) 2015-present, Facebook, Inc. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. An additional grant + * of patent rights can be found in the PATENTS file in the same directory. + */ + +'use strict'; + +jest.disableAutomock(); + +const {LazyPromise, LockingPromise} = require('../util'); + +describe('Lazy Promise', () => { + let factory; + const value = {}; + + beforeEach(() => { + factory = jest.fn(); + factory.mockReturnValue(Promise.resolve(value)); + }); + + it('does not run the factory by default', () => { + new LazyPromise(factory); // eslint-disable-line no-new + expect(factory).not.toBeCalled(); + }); + + it('calling `.then()` returns a promise', () => { + expect(new LazyPromise(factory).then()).toBeInstanceOf(Promise); + }); + + it('does not invoke the factory twice', () => { + const p = new LazyPromise(factory); + p.then(x => x); + p.then(x => x); + expect(factory).toHaveBeenCalledTimes(1); + }); + + describe('value and error propagation', () => { + it('resolves to the value provided by the factory', () => { + expect.assertions(1); + return new LazyPromise(factory) + .then(v => expect(v).toBe(value)); + }); + + it('passes through errors if not handled', () => { + const error = new Error('Unhandled'); + factory.mockReturnValue(Promise.reject(error)); + + expect.assertions(1); + return new LazyPromise(factory) + .then() + .catch(e => expect(e).toBe(error)); + }); + + it('uses rejection handlers passed to `then()`', () => { + const error = new Error('Must be handled'); + factory.mockReturnValue(Promise.reject(error)); + + expect.assertions(1); + return new LazyPromise(factory) + .then(() => {}, e => expect(e).toBe(error)); + }); + + it('uses rejection handlers passed to `catch()`', () => { + const error = new Error('Must be handled'); + factory.mockReturnValue(Promise.reject(error)); + + expect.assertions(1); + return new LazyPromise(factory) + .catch(e => expect(e).toBe(error)); + }); + }); +}); + +describe('Locking Promise', () => { + it('resolves to the value of the passed-in promise', () => { + const value = {}; + + expect.assertions(1); + return new LockingPromise(Promise.resolve(value)) + .then(v => expect(v).toBe(value)); + }); + + it('passes through rejections', () => { + const error = new Error('Rejection'); + + expect.assertions(1); + return new LockingPromise(Promise.reject(error)) + .then() + .catch(e => expect(e).toBe(error)); + }); + + it('uses rejection handlers passed to `then()`', () => { + const error = new Error('Must be handled'); + + expect.assertions(1); + return new LockingPromise(Promise.reject(error)) + .then(x => x, e => expect(e).toBe(error)); + }); + + it('uses rejection handlers passed to `catch()`', () => { + const error = new Error('Must be handled'); + + expect.assertions(1); + return new LockingPromise(Promise.reject(error)) + .catch(e => expect(e).toBe(error)); + }); + + describe('locking', () => { + const value = Symbol; + let locking; + beforeEach(() => { + locking = new LockingPromise(Promise.resolve(value)); + }); + + + it('only allows one handler to access the promise value', () => { + const deferred = defer(); + const secondHandler = jest.fn(); + locking.then(() => deferred.promise); + locking.then(secondHandler); + return Promise.resolve() // wait for the next tick + .then(() => expect(secondHandler).not.toBeCalled()); + }); + + it('allows waiting handlers to access the value after the current handler resolves', () => { + let counter = 0; + + const deferred = defer(); + const x = locking.then(v => { + const result = [++counter, v]; + return deferred.promise.then(() => result); + }); + const y = locking.then(v => [++counter, v]); + const z = locking.then(v => [++counter, v]); + + deferred.resolve(); + + return Promise.all([x, y, z]) + .then(([first, second, third]) => { + expect(first).toEqual([1, value]); + expect(second).toEqual([2, value]); + expect(third).toEqual([3, value]); + }); + }); + }); +}); + + +function defer() { + let resolve; + const promise = new Promise(res => { resolve = res; }); + return {promise, resolve}; +} diff --git a/packager/src/Server/symbolicate/__tests__/worker-test.js b/packager/src/Server/symbolicate/__tests__/worker-test.js new file mode 100644 index 000000000..62dee94e8 --- /dev/null +++ b/packager/src/Server/symbolicate/__tests__/worker-test.js @@ -0,0 +1,111 @@ +/** + * Copyright (c) 2015-present, Facebook, Inc. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. An additional grant + * of patent rights can be found in the PATENTS file in the same directory. + */ +'use strict'; + +jest.disableAutomock(); + +const SourceMapGenerator = require('../../../Bundler/source-map/Generator'); +const {symbolicate} = require('../worker'); + +let connection; +beforeEach(() => { + connection = {end: jest.fn()}; +}); + +it('symbolicates stack frames', () => { + const mappings = [ + { + from: {file: 'bundle1.js', lineNumber: 1, column: 2}, + to: {file: 'apples.js', lineNumber: 12, column: 34}, + }, + { + from: {file: 'bundle2.js', lineNumber: 3, column: 4}, + to: {file: 'bananas.js', lineNumber: 56, column: 78}, + }, + { + from: {file: 'bundle1.js', lineNumber: 5, column: 6}, + to: {file: 'clementines.js', lineNumber: 90, column: 12}, + }, + ]; + + const stack = mappings.map(m => m.to); + const maps = + Object.entries(groupBy(mappings, m => m.from.file)) + .map(([file, ms]) => [file, sourceMap(file, ms)]); + + return symbolicate(connection, makeData(stack, maps)) + .then(() => + expect(connection.end).toBeCalledWith( + JSON.stringify({result: mappings.map(m => m.to)}) + ) + ); +}); + +it('ignores stack frames without corresponding map', () => { + const frame = { + file: 'arbitrary.js', + lineNumber: 123, + column: 456, + }; + + return symbolicate(connection, makeData([frame], [['other.js', emptyMap()]])) + .then(() => + expect(connection.end).toBeCalledWith( + JSON.stringify({result: [frame]}) + ) + ); +}); + +it('ignores `/debuggerWorker.js` stack frames', () => { + const frame = { + file: 'http://localhost:8081/debuggerWorker.js', + lineNumber: 123, + column: 456, + }; + + return symbolicate(connection, makeData([frame])) + .then(() => + expect(connection.end).toBeCalledWith( + JSON.stringify({result: [frame]}) + ) + ); +}); + +function makeData(stack, maps = []) { + return JSON.stringify({maps, stack}); +} + +function sourceMap(file, mappings) { + const g = new SourceMapGenerator(); + g.startFile(file, null); + mappings.forEach(({from, to}) => + g.addSourceMapping(to.lineNumber, to.column, from.lineNumber, from.column)); + return g.toMap(); +} + +function groupBy(xs, key) { + const grouped = {}; + xs.forEach(x => { + const k = key(x); + if (k in grouped) { + grouped[k].push(x); + } else { + grouped[k] = [x]; + } + }); + return grouped; +} + +function emptyMap() { + return { + version: 3, + sources: [], + mappings: '', + }; +} diff --git a/packager/src/Server/symbolicate/package.json b/packager/src/Server/symbolicate/package.json new file mode 100644 index 000000000..901fd0b6a --- /dev/null +++ b/packager/src/Server/symbolicate/package.json @@ -0,0 +1 @@ +{"main": "./symbolicate.js"} diff --git a/packager/src/Server/symbolicate/symbolicate.js b/packager/src/Server/symbolicate/symbolicate.js new file mode 100644 index 000000000..4f5dcdc2e --- /dev/null +++ b/packager/src/Server/symbolicate/symbolicate.js @@ -0,0 +1,76 @@ +/** + * Copyright (c) 2015-present, Facebook, Inc. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. An additional grant + * of patent rights can be found in the PATENTS file in the same directory. + * + * @flow + */ +'use strict'; + +const concat = require('concat-stream'); +const debug = require('debug')('RNP:Symbolication'); +const net = require('net'); +const temp = require('temp'); +const xpipe = require('xpipe'); + +const {LazyPromise, LockingPromise} = require('./util'); +const {fork} = require('child_process'); + +export type {SourceMap as SourceMap}; +import type {MixedSourceMap as SourceMap} from '../../lib/SourceMap'; + +export type Stack = Array<{file: string, lineNumber: number, column: number}>; +export type Symbolicate = + (Stack, Iterable<[string, SourceMap]>) => Promise; + +const affixes = {prefix: 'metro-bundler-symbolicate', suffix: '.sock'}; +const childPath = require.resolve('./worker'); + +exports.createWorker = (): Symbolicate => { + const socket = xpipe.eq(temp.path(affixes)); + const child = new LockingPromise(new LazyPromise(() => startupChild(socket))); + + return (stack, sourceMaps) => + child + .then(() => connectAndSendJob(socket, message(stack, sourceMaps))) + .then(JSON.parse) + .then(response => + 'error' in response + ? Promise.reject(new Error(response.error)) + : response.result + ); +}; + +function startupChild(socket) { + const child = fork(childPath); + return new Promise((resolve, reject) => { + child + .once('error', reject) + .once('message', () => { + child.removeAllListeners(); + resolve(child); + }); + child.send(socket); + }); +} + +function connectAndSendJob(socket, data) { + const job = new Promise((resolve, reject) => { + debug('Connecting to worker'); + const connection = net.createConnection(socket); + connection.setEncoding('utf8'); + connection.on('error', reject); + connection.pipe(concat(resolve)); + debug('Sending data to worker'); + connection.end(data); + }); + job.then(() => debug('Received response from worker')); + return job; +} + +function message(stack, sourceMaps) { + return JSON.stringify({maps: Array.from(sourceMaps), stack}); +} diff --git a/packager/src/Server/symbolicate/util.js b/packager/src/Server/symbolicate/util.js new file mode 100644 index 000000000..858c21823 --- /dev/null +++ b/packager/src/Server/symbolicate/util.js @@ -0,0 +1,86 @@ +/** + * Copyright (c) 2015-present, Facebook, Inc. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. An additional grant + * of patent rights can be found in the PATENTS file in the same directory. + * + * @flow + */ + +'use strict'; + +type PromiseLike = { + catch(onReject?: (error: any) => ?Promise | U): Promise, + then( + fulfilled?: R => Promise | U, + rejected?: (error: any) => Promise | U, + ): Promise, +}; + +/** + * A promise-like object that only creates the underlying value lazily + * when requested. + */ +exports.LazyPromise = class LazyPromise { + _promise: PromiseLike; + + constructor(factory: () => PromiseLike) { + //$FlowIssue #16209141 + Object.defineProperty(this, '_promise', { + configurable: true, + enumerable: true, + get: () => (this._promise = factory()), + set: value => Object.defineProperty(this, '_promise', {value}), + }); + } + + then( + fulfilled?: (value: T) => Promise | U, + rejected?: (error: any) => Promise | U + ): Promise { + return this._promise.then(fulfilled, rejected); + } + + catch( + rejected?: (error: any) => ?Promise | U + ): Promise { + return this._promise.catch(rejected); + } +}; + +/** + * A promise-like object that allows only one `.then()` handler to access + * the wrapped value simultaneously. Can be used to lock resources that do + * asynchronous work. + */ +exports.LockingPromise = class LockingPromise { + _gate: PromiseLike + _promise: PromiseLike + + constructor(promise: PromiseLike) { + this._gate = this._promise = promise; + } + + then( + fulfilled?: (value: T) => Promise | U, + rejected?: (error: any) => Promise | U + ): Promise { + const whenUnlocked = () => { + const promise = this._promise.then(fulfilled, rejected); + this._gate = promise.then(empty); // avoid retaining the result of promise + return promise; + }; + + return this._gate.then(whenUnlocked, whenUnlocked); + } + + catch( + rejected?: (error: any) => ?Promise | U + ): Promise { + return this._promise.catch(rejected); + } +}; + +function empty() {} diff --git a/packager/src/Server/symbolicate/worker.js b/packager/src/Server/symbolicate/worker.js new file mode 100644 index 000000000..21a70022c --- /dev/null +++ b/packager/src/Server/symbolicate/worker.js @@ -0,0 +1,77 @@ +/** + * Copyright (c) 2015-present, Facebook, Inc. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. An additional grant + * of patent rights can be found in the PATENTS file in the same directory. + */ + +'use strict'; + +// RUNS UNTRANSFORMED IN NODE >= v4 +// NO FANCY FEATURES, E.G. DESTRUCTURING, PLEASE! + +const SourceMapConsumer = require('source-map').SourceMapConsumer; +const concat = require('concat-stream'); +const net = require('net'); + +process.once('message', socket => { + net.createServer({allowHalfOpen: true}, connection => { + connection.setEncoding('utf8'); + connection.pipe(concat(data => + symbolicate(connection, data) + .catch(console.error) // log the error as a last resort + )); + }).listen(socket, () => process.send(null)); +}); + +function symbolicate(connection, data) { + return Promise.resolve(data) + .then(JSON.parse) + .then(symbolicateStack) + .then(JSON.stringify) + .catch(makeErrorMessage) + .then(message => connection.end(message)); +} + +function symbolicateStack(data) { + const consumers = new Map(data.maps.map(mapToConsumer)); + return { + result: data.stack.map(frame => mapFrame(frame, consumers)), + }; +} + +function mapFrame(frame, consumers) { + const sourceUrl = frame.file; + const consumer = consumers.get(sourceUrl); + if (consumer == null) { + return frame; + } + const original = consumer.originalPositionFor({ + line: frame.lineNumber, + column: frame.column, + }); + if (!original) { + return frame; + } + return Object.assign({}, frame, { + file: original.source, + lineNumber: original.line, + column: original.column, + }); +} + +function makeErrorMessage(error) { + return JSON.stringify({ + error: String(error && error.message || error), + }); +} + +function mapToConsumer(tuple) { + tuple[1] = new SourceMapConsumer(tuple[1]); + return tuple; +} + +// for testing +exports.symbolicate = symbolicate;