diff --git a/README.md b/README.md index c02b95d6..82f07770 100644 --- a/README.md +++ b/README.md @@ -168,6 +168,8 @@ Certain options can be provided in the `options` object of *any* method that cal (Note that if the ONLY option you want to provide is a callback, you can pass the callback function directly as the `options` parameter instead of passing an object with a `callback` property.) * `maxEditLength`: a number specifying the maximum edit distance to consider between the old and new texts. If the edit distance is higher than this, jsdiff will return `undefined` instead of a diff. You can use this to limit the computational cost of diffing large, very different texts by giving up early if the cost will be huge. Works for functions that return change objects and also for `structuredPatch`, but not other patch-generation functions. +* `timeout`: a number of milliseconds after which the diffing algorithm will abort and return `undefined`. Supported by the same functions as `maxEditLength`. + ### Defining custom diffing behaviors If you need behavior a little different to what any of the text diffing functions above offer, you can roll your own by customizing both the tokenization behavior used and the notion of equality used to determine if two tokens are equal. diff --git a/release-notes.md b/release-notes.md index 3919c740..b7c36f48 100644 --- a/release-notes.md +++ b/release-notes.md @@ -10,6 +10,7 @@ - [#344](https://github.com/kpdecker/jsdiff/issues/344) `diffLines`, `createTwoFilesPatch`, and other patch-creation methods now take an optional `stripTrailingCr: true` option which causes Windows-style `\r\n` line endings to be replaced with Unix-style `\n` line endings before calculating the diff, just like GNU `diff`'s `--strip-trailing-cr` flag. - [#451](https://github.com/kpdecker/jsdiff/pull/451) Added `diff.formatPatch`. - [#450](https://github.com/kpdecker/jsdiff/pull/450) Added `diff.reversePatch`. +- [#478](https://github.com/kpdecker/jsdiff/pull/478) Added `timeout` option. ## v5.1.0 diff --git a/src/diff/base.js b/src/diff/base.js index 99f93d7a..9e5f0aea 100644 --- a/src/diff/base.js +++ b/src/diff/base.js @@ -33,6 +33,8 @@ Diff.prototype = { if(options.maxEditLength) { maxEditLength = Math.min(maxEditLength, options.maxEditLength); } + const maxExecutionTime = options.timeout ?? Infinity; + const abortAfterTimestamp = Date.now() + maxExecutionTime; let bestPath = [{ oldPos: -1, lastComponent: undefined }]; @@ -128,7 +130,7 @@ Diff.prototype = { if (callback) { (function exec() { setTimeout(function() { - if (editLength > maxEditLength) { + if (editLength > maxEditLength || Date.now() > abortAfterTimestamp) { return callback(); } @@ -138,7 +140,7 @@ Diff.prototype = { }, 0); }()); } else { - while (editLength <= maxEditLength) { + while (editLength <= maxEditLength && Date.now() <= abortAfterTimestamp) { let ret = execEditLength(); if (ret) { return ret; diff --git a/test/diff/array.js b/test/diff/array.js index 33dcf61a..455ac118 100644 --- a/test/diff/array.js +++ b/test/diff/array.js @@ -75,5 +75,27 @@ describe('diff/array', function() { {count: 1, value: [d], removed: undefined, added: true} ]); }); + it('Should terminate early if execution time exceeds `timeout` ms', function() { + // To test this, we also pass a comparator that hot sleeps as a way to + // artificially slow down execution so we reach the timeout. + function comparator(left, right) { + const start = Date.now(); + // Hot-sleep for 10ms + while (Date.now() < start + 10) { + // Do nothing + } + return left === right; + } + + // It will require 14 comparisons (140ms) to diff these arrays: + const arr1 = ['a', 'b', 'c', 'd', 'e', 'f', 'g']; + const arr2 = ['a', 'b', 'c', 'd', 'x', 'y', 'z']; + + // So with a timeout of 50ms, we are guaranteed failure: + expect(diffArrays(arr1, arr2, {comparator, timeout: 50})).to.be.undefined; + + // But with a longer timeout, we expect success: + expect(diffArrays(arr1, arr2, {comparator, timeout: 1000})).not.to.be.undefined; + }); }); });