Skip to content

Commit bf45b03

Browse files
Fix race conditions involving this.options being overwritten during execution in async mode (#489)
* Add test demonstrating race condition with this.options * Update signature to reflect #490 * Eliminate this.options * Add release notes * Update docs
1 parent 13d9749 commit bf45b03

File tree

7 files changed

+56
-42
lines changed

7 files changed

+56
-42
lines changed

README.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -177,10 +177,10 @@ To customize the notion of token equality used, use the `comparator` option to `
177177

178178
For even more customisation of the diffing behavior, you can create a `new Diff.Diff()` object, overwrite its `castInput`, `tokenize`, `removeEmpty`, `equals`, and `join` properties with your own functions, then call its `diff(oldString, newString[, options])` method. The methods you can overwrite are used as follows:
179179

180-
* `castInput(value)`: used to transform the `oldString` and `newString` before any other steps in the diffing algorithm happen. For instance, `diffJson` uses `castInput` to serialize the objects being diffed to JSON. Defaults to a no-op.
181-
* `tokenize(value)`: used to convert each of `oldString` and `newString` (after they've gone through `castInput`) to an array of tokens. Defaults to returning `value.split('')` (returning an array of individual characters).
180+
* `castInput(value, options)`: used to transform the `oldString` and `newString` before any other steps in the diffing algorithm happen. For instance, `diffJson` uses `castInput` to serialize the objects being diffed to JSON. Defaults to a no-op.
181+
* `tokenize(value, options)`: used to convert each of `oldString` and `newString` (after they've gone through `castInput`) to an array of tokens. Defaults to returning `value.split('')` (returning an array of individual characters).
182182
* `removeEmpty(array)`: called on the arrays of tokens returned by `tokenize` and can be used to modify them. Defaults to stripping out falsey tokens, such as empty strings. `diffArrays` overrides this to simply return the `array`, which means that falsey values like empty strings can be handled like any other token by `diffArrays`.
183-
* `equals(left, right)`: called to determine if two tokens (one from the old string, one from the new string) should be considered equal. Defaults to comparing them with `===`.
183+
* `equals(left, right, options)`: called to determine if two tokens (one from the old string, one from the new string) should be considered equal. Defaults to comparing them with `===`.
184184
* `join(tokens)`: gets called with an array of consecutive tokens that have either all been added, all been removed, or are all common. Needs to join them into a single value that can be used as the `value` property of the [change object](#change-objects) for these tokens. Defaults to simply returning `tokens.join('')`.
185185
186186
### Change Objects

release-notes.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
- [#480](https://github.com/kpdecker/jsdiff/pull/480) Passing `maxEditLength` to `createPatch` & `createTwoFilesPatch` now works properly (i.e. returns undefined if the max edit distance is exceeded; previous behavior was to crash with a `TypeError` if the edit distance was exceeded).
1414
- [#486](https://github.com/kpdecker/jsdiff/pull/486) The `ignoreWhitespace` option of `diffLines` behaves more sensibly now. `value`s in returned change objects now include leading/trailing whitespace even when `ignoreWhitespace` is used, just like how with `ignoreCase` the `value`s still reflect the case of one of the original texts instead of being all-lowercase. `ignoreWhitespace` is also now compatible with `newlineIsToken`. Finally, `diffTrimmedLines` is deprecated (and removed from the docs) in favour of using `diffLines` with `ignoreWhitespace: true`; the two are, and always have been, equivalent.
1515
- [#490](https://github.com/kpdecker/jsdiff/pull/490) When calling diffing functions in async mode by passing a `callback` option, the diff result will now be passed as the *first* argument to the callback instead of the second. (Previously, the first argument was never used at all and would always have value `undefined`.)
16+
- [#489](github.com/kpdecker/jsdiff/pull/489) `this.options` no longer exists on `Diff` objects. Instead, `options` is now passed as an argument to methods that rely on options, like `equals(left, right, options)`. This fixes a race condition in async mode, where diffing behaviour could be changed mid-execution if a concurrent usage of the same `Diff` instances overwrote its `options`.
1617

1718
## v5.2.0
1819

src/diff/base.js

Lines changed: 23 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@ Diff.prototype = {
77
callback = options;
88
options = {};
99
}
10-
this.options = options;
1110

1211
let self = this;
1312

@@ -21,11 +20,11 @@ Diff.prototype = {
2120
}
2221

2322
// Allow subclasses to massage the input prior to running
24-
oldString = this.castInput(oldString);
25-
newString = this.castInput(newString);
23+
oldString = this.castInput(oldString, options);
24+
newString = this.castInput(newString, options);
2625

27-
oldString = this.removeEmpty(this.tokenize(oldString));
28-
newString = this.removeEmpty(this.tokenize(newString));
26+
oldString = this.removeEmpty(this.tokenize(oldString, options));
27+
newString = this.removeEmpty(this.tokenize(newString, options));
2928

3029
let newLen = newString.length, oldLen = oldString.length;
3130
let editLength = 1;
@@ -39,10 +38,10 @@ Diff.prototype = {
3938
let bestPath = [{ oldPos: -1, lastComponent: undefined }];
4039

4140
// Seed editLength = 0, i.e. the content starts with the same values
42-
let newPos = this.extractCommon(bestPath[0], newString, oldString, 0);
41+
let newPos = this.extractCommon(bestPath[0], newString, oldString, 0, options);
4342
if (bestPath[0].oldPos + 1 >= oldLen && newPos + 1 >= newLen) {
4443
// Identity per the equality and tokenizer
45-
return done(buildValues(self, bestPath[0].lastComponent, newString, oldString, self.useLongestToken));
44+
return done(buildValues(self, bestPath[0].lastComponent, newString, oldString, self.useLongestToken, options));
4645
}
4746

4847
// Once we hit the right edge of the edit graph on some diagonal k, we can
@@ -97,16 +96,16 @@ Diff.prototype = {
9796
// path whose position in the old string is the farthest from the origin
9897
// and does not pass the bounds of the diff graph
9998
if (!canRemove || (canAdd && removePath.oldPos < addPath.oldPos)) {
100-
basePath = self.addToPath(addPath, true, false, 0);
99+
basePath = self.addToPath(addPath, true, false, 0, options);
101100
} else {
102-
basePath = self.addToPath(removePath, false, true, 1);
101+
basePath = self.addToPath(removePath, false, true, 1, options);
103102
}
104103

105-
newPos = self.extractCommon(basePath, newString, oldString, diagonalPath);
104+
newPos = self.extractCommon(basePath, newString, oldString, diagonalPath, options);
106105

107106
if (basePath.oldPos + 1 >= oldLen && newPos + 1 >= newLen) {
108107
// If we have hit the end of both strings, then we are done
109-
return done(buildValues(self, basePath.lastComponent, newString, oldString, self.useLongestToken));
108+
return done(buildValues(self, basePath.lastComponent, newString, oldString, self.useLongestToken, options));
110109
} else {
111110
bestPath[diagonalPath] = basePath;
112111
if (basePath.oldPos + 1 >= oldLen) {
@@ -147,9 +146,9 @@ Diff.prototype = {
147146
}
148147
},
149148

150-
addToPath(path, added, removed, oldPosInc) {
149+
addToPath(path, added, removed, oldPosInc, options) {
151150
let last = path.lastComponent;
152-
if (last && !this.options.oneChangePerToken && last.added === added && last.removed === removed) {
151+
if (last && !options.oneChangePerToken && last.added === added && last.removed === removed) {
153152
return {
154153
oldPos: path.oldPos + oldPosInc,
155154
lastComponent: {count: last.count + 1, added: added, removed: removed, previousComponent: last.previousComponent }
@@ -161,36 +160,36 @@ Diff.prototype = {
161160
};
162161
}
163162
},
164-
extractCommon(basePath, newString, oldString, diagonalPath) {
163+
extractCommon(basePath, newString, oldString, diagonalPath, options) {
165164
let newLen = newString.length,
166165
oldLen = oldString.length,
167166
oldPos = basePath.oldPos,
168167
newPos = oldPos - diagonalPath,
169168

170169
commonCount = 0;
171-
while (newPos + 1 < newLen && oldPos + 1 < oldLen && this.equals(oldString[oldPos + 1], newString[newPos + 1])) {
170+
while (newPos + 1 < newLen && oldPos + 1 < oldLen && this.equals(oldString[oldPos + 1], newString[newPos + 1], options)) {
172171
newPos++;
173172
oldPos++;
174173
commonCount++;
175-
if (this.options.oneChangePerToken) {
174+
if (options.oneChangePerToken) {
176175
basePath.lastComponent = {count: 1, previousComponent: basePath.lastComponent, added: false, removed: false};
177176
}
178177
}
179178

180-
if (commonCount && !this.options.oneChangePerToken) {
179+
if (commonCount && !options.oneChangePerToken) {
181180
basePath.lastComponent = {count: commonCount, previousComponent: basePath.lastComponent, added: false, removed: false};
182181
}
183182

184183
basePath.oldPos = oldPos;
185184
return newPos;
186185
},
187186

188-
equals(left, right) {
189-
if (this.options.comparator) {
190-
return this.options.comparator(left, right);
187+
equals(left, right, options) {
188+
if (options.comparator) {
189+
return options.comparator(left, right);
191190
} else {
192191
return left === right
193-
|| (this.options.ignoreCase && left.toLowerCase() === right.toLowerCase());
192+
|| (options.ignoreCase && left.toLowerCase() === right.toLowerCase());
194193
}
195194
},
196195
removeEmpty(array) {
@@ -213,7 +212,7 @@ Diff.prototype = {
213212
}
214213
};
215214

216-
function buildValues(diff, lastComponent, newString, oldString, useLongestToken) {
215+
function buildValues(diff, lastComponent, newString, oldString, useLongestToken, options) {
217216
// First we convert our linked list of components in reverse order to an
218217
// array in the right order:
219218
const components = [];
@@ -265,9 +264,9 @@ function buildValues(diff, lastComponent, newString, oldString, useLongestToken)
265264
componentLen > 1
266265
&& typeof finalComponent.value === 'string'
267266
&& (
268-
(finalComponent.added && diff.equals('', finalComponent.value))
267+
(finalComponent.added && diff.equals('', finalComponent.value, options))
269268
||
270-
(finalComponent.removed && diff.equals(finalComponent.value, ''))
269+
(finalComponent.removed && diff.equals(finalComponent.value, '', options))
271270
)
272271
) {
273272
components[componentLen - 2].value += finalComponent.value;

src/diff/json.js

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,13 +10,13 @@ export const jsonDiff = new Diff();
1010
jsonDiff.useLongestToken = true;
1111

1212
jsonDiff.tokenize = lineDiff.tokenize;
13-
jsonDiff.castInput = function(value) {
14-
const {undefinedReplacement, stringifyReplacer = (k, v) => typeof v === 'undefined' ? undefinedReplacement : v} = this.options;
13+
jsonDiff.castInput = function(value, options) {
14+
const {undefinedReplacement, stringifyReplacer = (k, v) => typeof v === 'undefined' ? undefinedReplacement : v} = options;
1515

1616
return typeof value === 'string' ? value : JSON.stringify(canonicalize(value, null, null, stringifyReplacer), stringifyReplacer, ' ');
1717
};
18-
jsonDiff.equals = function(left, right) {
19-
return Diff.prototype.equals.call(jsonDiff, left.replace(/,([\r\n])/g, '$1'), right.replace(/,([\r\n])/g, '$1'));
18+
jsonDiff.equals = function(left, right, options) {
19+
return Diff.prototype.equals.call(jsonDiff, left.replace(/,([\r\n])/g, '$1'), right.replace(/,([\r\n])/g, '$1'), options);
2020
};
2121

2222
export function diffJson(oldObj, newObj, options) { return jsonDiff.diff(oldObj, newObj, options); }

src/diff/line.js

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,8 @@ import Diff from './base';
22
import {generateOptions} from '../util/params';
33

44
export const lineDiff = new Diff();
5-
lineDiff.tokenize = function(value) {
6-
if(this.options.stripTrailingCr) {
5+
lineDiff.tokenize = function(value, options) {
6+
if(options.stripTrailingCr) {
77
// remove one \r before \n to match GNU diff's --strip-trailing-cr behavior
88
value = value.replace(/\r\n/g, '\n');
99
}
@@ -20,7 +20,7 @@ lineDiff.tokenize = function(value) {
2020
for (let i = 0; i < linesAndNewlines.length; i++) {
2121
let line = linesAndNewlines[i];
2222

23-
if (i % 2 && !this.options.newlineIsToken) {
23+
if (i % 2 && !options.newlineIsToken) {
2424
retLines[retLines.length - 1] += line;
2525
} else {
2626
retLines.push(line);
@@ -30,23 +30,23 @@ lineDiff.tokenize = function(value) {
3030
return retLines;
3131
};
3232

33-
lineDiff.equals = function(left, right) {
33+
lineDiff.equals = function(left, right, options) {
3434
// If we're ignoring whitespace, we need to normalise lines by stripping
3535
// whitespace before checking equality. (This has an annoying interaction
3636
// with newlineIsToken that requires special handling: if newlines get their
3737
// own token, then we DON'T want to trim the *newline* tokens down to empty
3838
// strings, since this would cause us to treat whitespace-only line content
3939
// as equal to a separator between lines, which would be weird and
4040
// inconsistent with the documented behavior of the options.)
41-
if (this.options.ignoreWhitespace) {
42-
if (!this.options.newlineIsToken || !left.includes('\n')) {
41+
if (options.ignoreWhitespace) {
42+
if (!options.newlineIsToken || !left.includes('\n')) {
4343
left = left.trim();
4444
}
45-
if (!this.options.newlineIsToken || !right.includes('\n')) {
45+
if (!options.newlineIsToken || !right.includes('\n')) {
4646
right = right.trim();
4747
}
4848
}
49-
return Diff.prototype.equals.call(this, left, right);
49+
return Diff.prototype.equals.call(this, left, right, options);
5050
};
5151

5252
export function diffLines(oldStr, newStr, callback) { return lineDiff.diff(oldStr, newStr, callback); }

src/diff/word.js

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,12 +24,12 @@ const extendedWordChars = /^[a-zA-Z\u{C0}-\u{FF}\u{D8}-\u{F6}\u{F8}-\u{2C6}\u{2C
2424
const reWhitespace = /\S/;
2525

2626
export const wordDiff = new Diff();
27-
wordDiff.equals = function(left, right) {
28-
if (this.options.ignoreCase) {
27+
wordDiff.equals = function(left, right, options) {
28+
if (options.ignoreCase) {
2929
left = left.toLowerCase();
3030
right = right.toLowerCase();
3131
}
32-
return left === right || (this.options.ignoreWhitespace && !reWhitespace.test(left) && !reWhitespace.test(right));
32+
return left === right || (options.ignoreWhitespace && !reWhitespace.test(left) && !reWhitespace.test(right));
3333
};
3434
wordDiff.tokenize = function(value) {
3535
// All whitespace symbols except newline group into one token, each newline - in separate token

test/diff/character.js

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,5 +38,19 @@ describe('diff/character', function() {
3838
expect(convertChangesToXML(diffResult)).to.equal('New value<del>s</del>.');
3939
});
4040
});
41+
42+
it('should not be susceptible to race conditions in async mode when called with different options', function(done) {
43+
// (regression test for https://github.com/kpdecker/jsdiff/issues/477)
44+
diffChars('wibblywobbly', 'WIBBLYWOBBLY', {ignoreCase: false, callback: (diffResult) => {
45+
expect(convertChangesToXML(diffResult)).to.equal('<del>wibblywobbly</del><ins>WIBBLYWOBBLY</ins>');
46+
done();
47+
}});
48+
49+
// Historically, doing this while async execution of the previous
50+
// diffChars call was ongoing would overwrite this.options and make the
51+
// ongoing diff become case-insensitive partway through execution.
52+
diffChars('whatever', 'whatever', {ignoreCase: true});
53+
diffChars('whatever', 'whatever', {ignoreCase: true, callback: () => {}});
54+
});
4155
});
4256
});

0 commit comments

Comments
 (0)