Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

core(dependency-graph): add cycle sanity check #3592

Merged
merged 1 commit into from
Oct 19, 2017
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions lighthouse-core/gather/computed/page-dependency-graph.js
Original file line number Diff line number Diff line change
Expand Up @@ -231,6 +231,10 @@ class PageDependencyGraphArtifact extends ComputedArtifact {
PageDependencyGraphArtifact.linkNetworkNodes(rootNode, networkNodeOutput, networkRecords);
PageDependencyGraphArtifact.linkCPUNodes(rootNode, networkNodeOutput, cpuNodes);

if (NetworkNode.hasCycle(rootNode)) {
throw new Error('Invalid dependency graph created, cycle detected');
}

return rootNode;
}

Expand Down
47 changes: 40 additions & 7 deletions lighthouse-core/lib/dependency-graph/node.js
Original file line number Diff line number Diff line change
Expand Up @@ -81,14 +81,8 @@ class Node {
*/
getRootNode() {
let rootNode = this;
let maxDepth = 1000;
while (rootNode._dependencies.length && maxDepth) {
while (rootNode._dependencies.length) {
rootNode = rootNode._dependencies[0];
maxDepth--;
}

if (!maxDepth) {
throw new Error('Maximum depth exceeded: getRootNode');
}

return rootNode;
Expand Down Expand Up @@ -211,6 +205,45 @@ class Node {

this._traversePaths(iterator, getNext);
}

/**
* Returns whether the given node has a cycle in its dependent graph by performing a DFS.
* @param {!Node} node
* @return {boolean}
*/
static hasCycle(node) {
const visited = new Set();
const currentPath = [];
const toVisit = [node];
const depthAdded = new Map([[node, 0]]);

// Keep going while we have nodes to visit in the stack
while (toVisit.length) {
// Get the last node in the stack (DFS uses stack, not queue)
const currentNode = toVisit.pop();

// We've hit a cycle if the node we're visiting is in our current dependency path
if (currentPath.includes(currentNode)) return true;
// If we've already visited the node, no need to revisit it
if (visited.has(currentNode)) continue;

// Since we're visiting this node, clear out any nodes in our path that we had to backtrack
while (currentPath.length > depthAdded.get(currentNode)) currentPath.pop();

// Update our data structures to reflect that we're adding this node to our path
visited.add(currentNode);
currentPath.push(currentNode);

// Add all of its dependents to our toVisit stack
for (const dependent of currentNode._dependents) {
if (toVisit.includes(dependent)) continue;
toVisit.push(dependent);
depthAdded.set(dependent, currentPath.length);
}
}

return false;
}
}

Node.TYPES = {
Expand Down
11 changes: 0 additions & 11 deletions lighthouse-core/lib/dependency-graph/simulator/simulator.js
Original file line number Diff line number Diff line change
Expand Up @@ -365,16 +365,13 @@ class Simulator {
const rootNode = this._graph.getRootNode();
rootNode.traverse(node => nodesNotReadyToStart.add(node));

let depth = 0;
let totalElapsedTime = 0;

// root node is always ready to start
this._markNodeAsReadyToStart(rootNode, totalElapsedTime);

// loop as long as we have nodes in the queue or currently in progress
while (nodesReadyToStart.size || nodesInProgress.size) {
depth++;

// move all possible queued nodes to in progress
for (const node of nodesReadyToStart) {
this._startNodeIfPossible(node, totalElapsedTime);
Expand All @@ -391,14 +388,6 @@ class Simulator {
for (const node of nodesInProgress) {
this._updateProgressMadeInTimePeriod(node, minimumTime, totalElapsedTime);
}

if (depth > 10000) {
throw new Error('Maximum depth exceeded: estimate');
}
}

if (nodesNotReadyToStart.size !== 0) {
throw new Error(`Cycle detected: ${nodesNotReadyToStart.size} unused nodes`);
}

return {
Expand Down
74 changes: 74 additions & 0 deletions lighthouse-core/test/lib/dependency-graph/node-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -218,4 +218,78 @@ describe('DependencyGraph/Node', () => {
assert.deepEqual(ids, ['F', 'E', 'D', 'B', 'C', 'A']);
});
});

describe('#hasCycle', () => {
it('should return false for DAGs', () => {
const graph = createComplexGraph();
assert.equal(Node.hasCycle(graph.nodeA), false);
});

it('should return false for triangular DAGs', () => {
// B
// / \
// A - C
const nodeA = new Node('A');
const nodeB = new Node('B');
const nodeC = new Node('C');

nodeA.addDependent(nodeC);
nodeA.addDependent(nodeB);
nodeB.addDependent(nodeC);

assert.equal(Node.hasCycle(nodeA), false);
});

it('should return true for basic cycles', () => {
const nodeA = new Node('A');
const nodeB = new Node('B');
const nodeC = new Node('C');

nodeA.addDependent(nodeB);
nodeB.addDependent(nodeC);
nodeC.addDependent(nodeA);

assert.equal(Node.hasCycle(nodeA), true);
});

it('should return true for complex cycles', () => {
// B - D - F - G - C!
// / /
// A - - C - E - H
const nodeA = new Node('A');
const nodeB = new Node('B');
const nodeC = new Node('C');
const nodeD = new Node('D');
const nodeE = new Node('E');
const nodeF = new Node('F');
const nodeG = new Node('G');
const nodeH = new Node('H');

nodeA.addDependent(nodeB);
nodeA.addDependent(nodeC);
nodeB.addDependent(nodeD);
nodeC.addDependent(nodeE);
nodeC.addDependent(nodeF);
nodeD.addDependent(nodeF);
nodeE.addDependent(nodeH);
nodeF.addDependent(nodeG);
nodeG.addDependent(nodeC);

assert.equal(Node.hasCycle(nodeA), true);
});

it('works for very large graphs', () => {
const root = new Node('root');

let lastNode = root;
for (let i = 0; i < 10000; i++) {
const nextNode = new Node(`child${i}`);
lastNode.addDependent(nextNode);
lastNode = nextNode;
}

lastNode.addDependent(root);
assert.equal(Node.hasCycle(root), true);
});
});
});