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

jest-runtime: atomic cache write, and check validity of data #4088

Merged
merged 1 commit into from
Jul 25, 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
1 change: 1 addition & 0 deletions packages/jest-runtime/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
"json-stable-stringify": "^1.0.1",
"micromatch": "^2.3.11",
"strip-bom": "3.0.0",
"write-file-atomic": "^2.1.0",
"yargs": "^7.0.2"
},
"devDependencies": {
Expand Down
39 changes: 24 additions & 15 deletions packages/jest-runtime/src/__tests__/script_transformer.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,11 @@
*/
'use strict';

const crypto = require('crypto');
const slash = require('slash');

jest
.mock('fs')
.mock('graceful-fs')
.mock('jest-haste-map', () => ({
getCacheFilePath: (cacheDir, baseDir, version) => cacheDir + baseDir,
Expand Down Expand Up @@ -100,6 +102,14 @@ let fs;
let mockFs;
let object;
let vm;
let writeFileAtomic;

jest.mock('write-file-atomic', () => ({
sync: jest.fn().mockImplementation((filePath, data) => {
const normalizedPath = require('slash')(filePath);
mockFs[normalizedPath] = data;
}),
}));

describe('ScriptTransformer', () => {
const reset = () => {
Expand Down Expand Up @@ -145,6 +155,8 @@ describe('ScriptTransformer', () => {

fs.existsSync = jest.fn(path => !!mockFs[path]);

writeFileAtomic = require('write-file-atomic');

config = {
cache: true,
cacheDirectory: '/cache/',
Expand Down Expand Up @@ -290,11 +302,10 @@ describe('ScriptTransformer', () => {
mapCoverage: true,
});
expect(result.sourceMapPath).toEqual(expect.any(String));
expect(fs.writeFileSync).toBeCalledWith(
result.sourceMapPath,
JSON.stringify(map),
'utf8',
);
const mapStr = JSON.stringify(map);
expect(writeFileAtomic.sync).toBeCalledWith(result.sourceMapPath, mapStr, {
encoding: 'utf8',
});
});

it('writes source map if preprocessor inlines it', () => {
Expand All @@ -320,11 +331,9 @@ describe('ScriptTransformer', () => {
mapCoverage: true,
});
expect(result.sourceMapPath).toEqual(expect.any(String));
expect(fs.writeFileSync).toBeCalledWith(
result.sourceMapPath,
sourceMap,
'utf8',
);
expect(
writeFileAtomic.sync,
).toBeCalledWith(result.sourceMapPath, sourceMap, {encoding: 'utf8'});
});

it('does not write source map if mapCoverage option is false', () => {
Expand All @@ -348,7 +357,7 @@ describe('ScriptTransformer', () => {
mapCoverage: false,
});
expect(result.sourceMapPath).toBeFalsy();
expect(fs.writeFileSync).toHaveBeenCalledTimes(1);
expect(writeFileAtomic.sync).toHaveBeenCalledTimes(1);
});

it('reads values from the cache', () => {
Expand All @@ -359,8 +368,8 @@ describe('ScriptTransformer', () => {
scriptTransformer.transform('/fruits/banana.js', {});

const cachePath = getCachePath(mockFs, config);
expect(fs.writeFileSync).toBeCalled();
expect(fs.writeFileSync.mock.calls[0][0]).toBe(cachePath);
expect(writeFileAtomic.sync).toBeCalled();
expect(writeFileAtomic.sync.mock.calls[0][0]).toBe(cachePath);

// Cache the state in `mockFsCopy`
const mockFsCopy = mockFs;
Expand All @@ -375,7 +384,7 @@ describe('ScriptTransformer', () => {
expect(fs.readFileSync.mock.calls.length).toBe(2);
expect(fs.readFileSync).toBeCalledWith('/fruits/banana.js', 'utf8');
expect(fs.readFileSync).toBeCalledWith(cachePath, 'utf8');
expect(fs.writeFileSync).not.toBeCalled();
expect(writeFileAtomic.sync).not.toBeCalled();

// Don't read from the cache when `config.cache` is false.
jest.resetModuleRegistry();
Expand All @@ -388,6 +397,6 @@ describe('ScriptTransformer', () => {
expect(fs.readFileSync.mock.calls.length).toBe(1);
expect(fs.readFileSync).toBeCalledWith('/fruits/banana.js', 'utf8');
expect(fs.readFileSync).not.toBeCalledWith(cachePath, 'utf8');
expect(fs.writeFileSync).toBeCalled();
expect(writeFileAtomic.sync).toBeCalled();
});
});
74 changes: 64 additions & 10 deletions packages/jest-runtime/src/script_transformer.js
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import stableStringify from 'json-stable-stringify';
import slash from 'slash';
import {version as VERSION} from '../package.json';
import shouldInstrument from './should_instrument';
import writeFileAtomic from 'write-file-atomic';

export type Options = {|
collectCoverage: boolean,
Expand All @@ -42,6 +43,9 @@ const configToJsonMap = new Map();
// Cache regular expressions to test whether the file needs to be preprocessed
const ignoreCache: WeakMap<ProjectConfig, ?RegExp> = new WeakMap();

// To reset the cache for specific changesets (rather than package version).
const CACHE_VERSION = '1';

class ScriptTransformer {
static EVAL_RESULT_VARIABLE: string;
_config: ProjectConfig;
Expand All @@ -67,16 +71,23 @@ class ScriptTransformer {
const transformer = this._getTransformer(filename);

if (transformer && typeof transformer.getCacheKey === 'function') {
return transformer.getCacheKey(fileData, filename, configString, {
instrument,
});
return crypto
.createHash('md5')
.update(
transformer.getCacheKey(fileData, filename, configString, {
instrument,
}),
)
.update(CACHE_VERSION)
.digest('hex');
} else {
return crypto
.createHash('md5')
.update(fileData)
.update(configString)
.update(instrument ? 'instrument' : '')
.update(mapCoverage ? 'mapCoverage' : '')
.update(CACHE_VERSION)
.digest('hex');
}
}
Expand Down Expand Up @@ -184,11 +195,13 @@ class ScriptTransformer {
);
let sourceMapPath = cacheFilePath + '.map';
// Ignore cache if `config.cache` is set (--no-cache)
let code = this._config.cache
? readCacheFile(filename, cacheFilePath)
: null;
let code = this._config.cache ? readCodeCacheFile(cacheFilePath) : null;

if (code) {
// This is broken: we return the code, and a path for the source map
// directly from the cache. But, nothing ensures the source map actually
// matches that source code. They could have gotten out-of-sync in case
// two separate processes write concurrently to the same cache files.
return {
code,
sourceMapPath,
Expand Down Expand Up @@ -248,7 +261,7 @@ class ScriptTransformer {
sourceMapPath = null;
}

writeCacheFile(cacheFilePath, code);
writeCodeCacheFile(cacheFilePath, code);

return {
code,
Expand Down Expand Up @@ -343,9 +356,46 @@ const stripShebang = content => {
}
};

/**
* This is like `writeCacheFile` but with an additional sanity checksum. We
* cannot use the same technique for source maps because we expose source map
* cache file paths directly to callsites, with the expectation they can read
* it right away. This is not a great system, because source map cache file
* could get corrupted, out-of-sync, etc.
*/
function writeCodeCacheFile(cachePath: Path, code: string) {
const checksum = crypto.createHash('md5').update(code).digest('hex');
writeCacheFile(cachePath, checksum + '\n' + code);
}

/**
* Read counterpart of `writeCodeCacheFile`. We verify that the content of the
* file matches the checksum, in case some kind of corruption happened. This
* could happen if an older version of `jest-runtime` writes non-atomically to
* the same cache, for example.
*/
function readCodeCacheFile(cachePath: Path): ?string {
const content = readCacheFile(cachePath);
if (content == null) {
return null;
}
const code = content.substr(33);
const checksum = crypto.createHash('md5').update(code).digest('hex');
if (checksum === content.substr(0, 32)) {
return code;
}
return null;
}

/**
* Writing to the cache atomically relies on 'rename' being atomic on most
* file systems. Doing atomic write reduces the risk of corruption by avoiding
* two processes to write to the same file at the same time. It also reduces
* the risk of reading a file that's being overwritten at the same time.
*/
const writeCacheFile = (cachePath: Path, fileData: string) => {
try {
fs.writeFileSync(cachePath, fileData, 'utf8');
writeFileAtomic.sync(cachePath, fileData, {encoding: 'utf8'});
} catch (e) {
e.message =
'jest: failed to cache transform results in: ' +
Expand All @@ -357,7 +407,7 @@ const writeCacheFile = (cachePath: Path, fileData: string) => {
}
};

const readCacheFile = (filename: Path, cachePath: Path): ?string => {
const readCacheFile = (cachePath: Path): ?string => {
if (!fs.existsSync(cachePath)) {
return null;
}
Expand All @@ -366,7 +416,11 @@ const readCacheFile = (filename: Path, cachePath: Path): ?string => {
try {
fileData = fs.readFileSync(cachePath, 'utf8');
} catch (e) {
e.message = 'jest: failed to read cache file: ' + cachePath;
e.message =
'jest: failed to read cache file: ' +
cachePath +
'\nFailure message: ' +
e.message;
removeFile(cachePath);
throw e;
}
Expand Down