diff --git a/src/component/plotly-graph/parse.js b/src/component/plotly-graph/parse.js index 0d005f13..fc007922 100644 --- a/src/component/plotly-graph/parse.js +++ b/src/component/plotly-graph/parse.js @@ -93,6 +93,10 @@ function parse (body, _opts, sendToRenderer) { result.width = parseDim(result, opts, 'width') result.height = parseDim(result, opts, 'height') + if (willFigureHang(result)) { + return errorOut(400, 'figure data is likely to make exporter hang, rejecting request') + } + sendToRenderer(null, result) } @@ -108,4 +112,106 @@ function parseDim (result, opts, dim) { } } +function willFigureHang (result) { + const data = result.figure.data + + // cap the number of traces + if (data.length > 200) return true + + let maxPtBudget = 0 + + for (let i = 0; i < data.length; i++) { + const trace = data[i] || {} + + // cap the number of points using a budget + maxPtBudget += estimateDataLength(trace) / maxPtsPerTrace(trace) + if (maxPtBudget > 1) return true + } +} + +// Consider the array of maximum length as a proxy to determine +// the number of points to be drawn. In general, this estimate +// can be (much) smaller than the true number of points plotted +// when it does not match the length of the other coordinate arrays. +function findMaxArrayLength (cont) { + const arrays = Object.keys(cont) + .filter(k => Array.isArray(cont[k])) + .map(k => cont[k]) + + const lengths = arrays.map(arr => { + if (Array.isArray(arr[0])) { + // 2D array case + return arr.reduce((a, r) => a + r.length, 0) + } else { + return arr.length + } + }) + + return Math.max(0, ...lengths) +} + +function estimateDataLength (trace) { + // special case for e.g. parcoords and splom traces + if (Array.isArray(trace.dimensions)) { + return trace.dimensions + .map(findMaxArrayLength) + .reduce((a, v) => a + v) + } + + return findMaxArrayLength(trace) +} + +function maxPtsPerTrace (trace) { + const type = trace.type || 'scatter' + + switch (type) { + case 'scattergl': + case 'splom': + case 'pointcloud': + return 1e7 + + case 'scatterpolargl': + case 'heatmap': + case 'heatmapgl': + return 1e6 + + case 'scatter3d': + case 'surface': + return 5e5 + + case 'mesh3d': + if ('alphahull' in trace && Number(trace.alphahull) >= 0) { + return 1000 + } else { + return 5e5 + } + + case 'parcoords': + return 5e5 + case 'scattermapbox': + return 5e5 + + case 'histogram': + case 'histogram2d': + case 'histogram2dcontour': + return 1e6 + + case 'box': + if (trace.boxpoints === 'all') { + return 5e4 + } else { + return 1e6 + } + case 'violin': + if (trace.points === 'all') { + return 5e4 + } else { + return 1e6 + } + + default: + return 5e4 + } +} + module.exports = parse diff --git a/test/unit/plotly-graph_test.js b/test/unit/plotly-graph_test.js index c9d9a22e..9f2a25d2 100644 --- a/test/unit/plotly-graph_test.js +++ b/test/unit/plotly-graph_test.js @@ -326,6 +326,196 @@ tap.test('parse:', t => { }) }) + t.test('should not access figures that a likely to make renderer hang', t => { + t.test('failing svg scatter case', t => { + var x = new Array(1e6) + + fn({ + data: [{ + type: 'scatter', + x: x + }] + }, {}, (errorCode, result) => { + t.equal(errorCode, 400, 'code') + t.type(result.msg, 'string', 'msg type') + t.end() + }) + }) + + t.test('passing scattergl case', t => { + var x = new Array(1e6) + + fn({ + data: [{ + type: 'scattergl', + x: x + }] + }, {}, (errorCode, result) => { + t.equal(errorCode, null, 'code') + t.type(result.figure, 'object', 'figure type') + t.end() + }) + }) + + t.test('failing parcoords case', t => { + var x = new Array(3e5) + + fn({ + data: [{ + type: 'parcoords', + dimensions: [{ + values: x + }, { + values: x + }] + }] + }, {}, (errorCode, result) => { + t.equal(errorCode, 400, 'code') + t.type(result.msg, 'string', 'msg type') + t.end() + }) + }) + + t.test('failing heatmap case', t => { + var z = [ + new Array(5e5), + new Array(5e5), + new Array(5e5) + ] + + fn({ + data: [{ + type: 'heatmap', + z: z + }] + }, {}, (errorCode, result) => { + t.equal(errorCode, 400, 'code') + t.type(result.msg, 'string', 'msg type') + t.end() + }) + }) + + t.test('failing case from too many traces', t => { + var data = new Array(3e3) + + fn({ + data: data + }, {}, (errorCode, result) => { + t.equal(errorCode, 400, 'code') + t.type(result.msg, 'string', 'msg type') + t.end() + }) + }) + + t.test('failing edge case (box with boxpoints all)', t => { + fn({ + data: [{ + type: 'box', + x: new Array(1e5), + boxpoints: 'all' + }] + }, {}, (errorCode, result) => { + t.equal(errorCode, 400, 'code') + t.type(result.msg, 'string', 'msg type') + t.end() + }) + }) + + t.test('failing edge case (violin with points all)', t => { + fn({ + data: [{ + type: 'violin', + x: new Array(1e5), + points: 'all' + }] + }, {}, (errorCode, result) => { + t.equal(errorCode, 400, 'code') + t.type(result.msg, 'string', 'msg type') + t.end() + }) + }) + + t.test('failing edge case (mesh3d and alphahull)', t => { + var data = new Array(2e3) + + fn({ + data: [{ + type: 'mesh3d', + x: data, + alphahull: 1 + }] + }, {}, (errorCode, result) => { + t.equal(errorCode, 400, 'code') + t.type(result.msg, 'string', 'msg type') + t.end() + }) + }) + + t.test('failing case from too many traces', t => { + var data = new Array(3e3) + + fn({ + data: data + }, {}, (errorCode, result) => { + t.equal(errorCode, 400, 'code') + t.type(result.msg, 'string', 'msg type') + t.end() + }) + }) + + t.test('failing edge case (to test budget)', t => { + fn({ + data: [{ + type: 'scatter', + x: new Array(4e4) // below 5e4 threshold + }, { + type: 'heatmap', + z: [ + new Array(5e5), // below 5e4 threshold + new Array(4e5) + ] + }] + }, {}, (errorCode, result) => { + t.equal(errorCode, 400, 'code') + t.type(result.msg, 'string', 'msg type') + t.end() + }) + }) + + t.test('failing edge case (to test budget of edge cases)', t => { + fn({ + data: [{ + type: 'violin', + points: 'all', + x: new Array(4e4) // below 5e4 threshold + }, { + type: 'box', + boxpoints: 'all', + x: new Array(4e4) // below 5e4 threshold + }] + }, {}, (errorCode, result) => { + t.equal(errorCode, 400, 'code') + t.type(result.msg, 'string', 'msg type') + t.end() + }) + }) + + t.test('failing case (with no arrays in starting trace)', t => { + fn({ + data: [{}, {}, { + type: 'scatter', + x: new Array(1e6) + }] + }, {}, (errorCode, result) => { + t.equal(errorCode, 400, 'code') + t.type(result.msg, 'string', 'msg type') + t.end() + }) + }) + + t.end() + }) + t.end() })