Skip to content

Commit 4b45c44

Browse files
Sebastian McKenziebestander
authored andcommitted
Fix race conditions in package linker (#137)
* fix race conditions in fs.copy by performing all operations as a queue - fixes #133, closes #126 * remove optional dependencies that fail compatibility check * replace fsevents in install script test with flow-bin as it installs properly under linux * make fs.copy more resillient to changing permissions * fs.copyBulk should call itself again when unlinking * unindent requires * revert bin linking concurrency back to 4
1 parent 98e5c60 commit 4b45c44

File tree

5 files changed

+219
-94
lines changed

5 files changed

+219
-94
lines changed

src/package-compatibility.js

Lines changed: 24 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,9 @@ import { MessageError } from "./errors.js";
1717
import map from "./util/map.js";
1818
import { entries } from "./util/misc.js";
1919

20+
let invariant = require("invariant");
2021
let semver = require("semver");
21-
let _ = require("lodash");
22+
let _ = require("lodash");
2223

2324
function isValid(items: Array<string>, actual: string): boolean {
2425
let isBlacklist = false;
@@ -71,12 +72,30 @@ export default class PackageCompatibility {
7172
}
7273

7374
check(info: Manifest) {
74-
let didError = false;
75-
let human = `${info.name}@${info.version}`;
75+
let didIgnore = false;
76+
let didError = false;
77+
let reporter = this.reporter;
78+
let human = `${info.name}@${info.version}`;
7679

7780
let pushError = (msg) => {
78-
didError = true;
79-
this.reporter.error(`${human}: ${msg}`);
81+
let ref = info.reference;
82+
invariant(ref, "expected package reference");
83+
84+
if (ref.optional) {
85+
ref.addIgnore(true);
86+
87+
reporter.warn(`${human}: ${msg}`);
88+
if (!didIgnore) {
89+
reporter.info(
90+
`${human} is an optional dependency and failed compatibility check. ` +
91+
"Excluding it from installation."
92+
);
93+
didIgnore = true;
94+
}
95+
} else {
96+
reporter.error(`${human}: ${msg}`);
97+
didError = true;
98+
}
8099
};
81100

82101
if (Array.isArray(info.os)) {

src/package-linker.js

Lines changed: 24 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -280,16 +280,30 @@ export default class PackageLinker {
280280
});
281281

282282
//
283-
let tickCopyModule = this.reporter.progress(flatTree.length);
284-
await promise.queue(flatTree, async function ([dest, { pkg, loc: src }]) {
285-
pkg.reference.setLocation(dest);
286-
await fs.mkdirp(dest);
287-
288-
let fresh = await fs.copy(src, dest);
289-
pkg.reference.setFresh(fresh);
290-
291-
tickCopyModule(dest);
292-
}, 4);
283+
let queue = [];
284+
for (let [dest, { pkg, loc: src }] of flatTree) {
285+
let ref = pkg.reference;
286+
invariant(ref, "expected package reference");
287+
288+
ref.setLocation(dest);
289+
queue.push({
290+
src,
291+
dest,
292+
onFresh() {
293+
if (ref) ref.setFresh(true);
294+
}
295+
});
296+
}
297+
let bar;
298+
await fs.copyBulk(queue, {
299+
onStart: (num: number) => {
300+
bar = this.reporter.progress(num);
301+
},
302+
303+
onProgress(src: string) {
304+
if (bar) bar(src);
305+
}
306+
});
293307

294308
//
295309
let tickBin = this.reporter.progress(flatTree.length);

src/package-reference.js

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import type Lockfile from "./lockfile/index.js";
1313
import type Config from "./config.js";
1414
import type { PackageRemote, Manifest } from "./types.js";
1515
import type PackageRequest from "./package-request.js";
16+
import type PackageResolver from "./package-resolver.js";
1617
import type { RegistryNames } from "./registries/index.js";
1718
import { entries } from "./util/misc.js";
1819
import { MessageError } from "./errors.js";
@@ -24,6 +25,7 @@ export default class PackageReference {
2425
remote: PackageRemote,
2526
saveForOffline: boolean,
2627
) {
28+
this.resolver = request.resolver;
2729
this.lockfile = request.rootLockfile;
2830
this.requests = [request];
2931
this.config = request.config;
@@ -63,6 +65,7 @@ export default class PackageReference {
6365
remote: PackageRemote;
6466
registry: RegistryNames;
6567
location: ?string;
68+
resolver: PackageResolver;
6669

6770
async getFolder(): Promise<string> {
6871
return this.config.registries[this.registry].folder;
@@ -122,12 +125,27 @@ export default class PackageReference {
122125
}
123126
}
124127

125-
addIgnore(ignore: boolean) {
128+
addIgnore(ignore: boolean, ancestry?: Set<PackageReference> = new Set) {
126129
// see comments in addOptional
127130
if (this.ignore == null) {
128131
this.ignore = ignore;
129132
} else if (!ignore) {
130133
this.ignore = false;
134+
} else {
135+
// we haven't changed our `ignore` so don't mess with
136+
// dependencies
137+
return;
138+
}
139+
140+
if (ancestry.has(this)) return;
141+
ancestry.add(this);
142+
143+
// go through and update all transitive dependencies to be ignored
144+
for (let pattern of this.dependencies) {
145+
let pkg = this.resolver.getResolvedPattern(pattern);
146+
if (!pkg) continue;
147+
148+
pkg.reference.addIgnore(ignore, ancestry);
131149
}
132150
}
133151
}

src/util/fs.js

Lines changed: 121 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
*/
1111

1212
import BlockingQueue from "./blocking-queue.js";
13+
import * as promise from "./promise.js";
1314
import { promisify } from "./promise.js";
1415
import map from "./map.js";
1516

@@ -31,86 +32,147 @@ export let lstat = promisify(fs.lstat);
3132
export let chmod = promisify(fs.chmod);
3233

3334
let fsSymlink = promisify(fs.symlink);
35+
let invariant = require("invariant");
3436
let stripBOM = require("strip-bom");
3537

36-
export async function copy(src: string, dest: string): Promise<boolean> {
37-
let srcStat = await lstat(src);
38-
let destFiles = [];
39-
let srcFiles = [];
38+
type CopyQueue = Array<{
39+
src: string,
40+
dest: string,
41+
onFresh?: ?() => void
42+
}>;
43+
44+
type CopyActions = Array<{
45+
src: string,
46+
dest: string,
47+
atime: number,
48+
mtime: number,
49+
mode: number
50+
}>;
51+
52+
async function buildActionsForCopy(queue: CopyQueue): Promise<CopyActions> {
53+
let actions: CopyActions = [];
54+
await init();
55+
return actions;
56+
57+
// custom concurrency logic as we're always executing stacks of 4 queue items
58+
// at a time due to the requirement to push items onto the queue
59+
async function init() {
60+
let items = queue.splice(0, 4);
61+
if (!items.length) return;
62+
63+
await Promise.all(items.map(build));
64+
return init();
65+
}
4066

41-
if (await exists(dest)) {
42-
let destStat = await lstat(dest);
67+
//
68+
async function build(data) {
69+
let { src, dest, onFresh } = data;
70+
let srcStat = await lstat(src);
71+
let srcFiles;
4372

44-
if (srcStat.mode !== destStat.mode) {
45-
// different types
46-
await access(dest, srcStat.mode);
73+
if (srcStat.isDirectory()) {
74+
srcFiles = await readdir(src);
4775
}
4876

49-
if (srcStat.isFile() && destStat.isFile() &&
50-
srcStat.size === destStat.size && +srcStat.mtime === +destStat.mtime) {
51-
// we can safely assume this is the same file
52-
return false;
53-
}
77+
if (await exists(dest)) {
78+
let destStat = await lstat(dest);
5479

55-
if (srcStat.isDirectory() && destStat.isDirectory()) {
56-
// remove files that aren't in source
57-
[destFiles, srcFiles] = await Promise.all([
58-
readdir(dest),
59-
readdir(src)
60-
]);
80+
let bothFiles = srcStat.isFile() && destStat.isFile();
81+
let bothFolders = !bothFiles && srcStat.isDirectory() && destStat.isDirectory();
6182

62-
let promises = destFiles.map(async (file) => {
63-
if (file !== "node_modules" && srcFiles.indexOf(file) < 0) {
64-
await unlink(path.join(dest, file));
83+
if (srcStat.mode !== destStat.mode) {
84+
if (bothFiles) {
85+
await access(dest, srcStat.mode);
86+
} else {
87+
await unlink(dest);
88+
return build(data);
6589
}
66-
});
90+
}
6791

68-
await Promise.all(promises);
69-
}
70-
}
92+
if (bothFiles && srcStat.size === destStat.size && +srcStat.mtime === +destStat.mtime) {
93+
// we can safely assume this is the same file
94+
return;
95+
}
7196

72-
if (srcStat.isDirectory()) {
73-
let anyFresh = false;
97+
if (bothFolders) {
98+
// remove files that aren't in source
99+
let destFiles = await readdir(dest);
100+
invariant(srcFiles, "src files not initialised");
74101

75-
// create dest directory
76-
await mkdirp(dest);
102+
for (let file of destFiles) {
103+
if (file === "node_modules") continue;
77104

78-
// copy all files from source to dest
79-
let promises = srcFiles.map((file) => {
80-
return copy(path.join(src, file), path.join(dest, file)).then(function (fresh) {
81-
if (fresh) anyFresh = true;
82-
return fresh;
105+
if (srcFiles.indexOf(file) < 0) {
106+
await unlink(path.join(dest, file));
107+
}
108+
}
109+
}
110+
}
111+
112+
if (srcStat.isDirectory()) {
113+
await mkdirp(dest);
114+
115+
// push all files to queue
116+
invariant(srcFiles, "src files not initialised");
117+
for (let file of srcFiles) {
118+
queue.push({
119+
onFresh,
120+
src: path.join(src, file),
121+
dest: path.join(dest, file)
122+
});
123+
}
124+
} else if (srcStat.isFile()) {
125+
if (onFresh) onFresh();
126+
actions.push({
127+
src,
128+
dest,
129+
atime: srcStat.atime,
130+
mtime: srcStat.mtime,
131+
mode: srcStat.mode
83132
});
84-
});
133+
} else {
134+
throw new Error("unsure how to copy this?");
135+
}
136+
}
137+
}
85138

86-
await Promise.all(promises);
139+
export function copy(src: string, dest: string): Promise<void> {
140+
return copyBulk([{ src, dest }]);
141+
}
87142

88-
return anyFresh;
89-
} else if (srcStat.isFile()) {
90-
return new Promise((resolve, reject) => {
91-
let readStream = fs.createReadStream(src);
92-
let writeStream = fs.createWriteStream(dest, { mode: srcStat.mode });
143+
export async function copyBulk(
144+
queue: CopyQueue,
145+
events?: {
146+
onProgress: (dest: string) => void,
147+
onStart: (num: number) => void
148+
}
149+
): Promise<void> {
150+
let actions: CopyActions = await buildActionsForCopy(queue);
93151

94-
readStream.on("error", reject);
95-
writeStream.on("error", reject);
152+
if (events) events.onStart(actions.length);
96153

97-
writeStream.on("open", function () {
98-
readStream.pipe(writeStream);
99-
});
154+
await promise.queue(actions, (data) => new Promise((resolve, reject) => {
155+
let readStream = fs.createReadStream(data.src);
156+
let writeStream = fs.createWriteStream(data.dest, { mode: data.mode });
100157

101-
writeStream.once("finish", function () {
102-
fs.utimes(dest, srcStat.atime, srcStat.mtime, function (err) {
103-
if (err) {
104-
reject(err);
105-
} else {
106-
resolve(true);
107-
}
108-
});
158+
readStream.on("error", reject);
159+
writeStream.on("error", reject);
160+
161+
writeStream.on("open", function () {
162+
readStream.pipe(writeStream);
163+
});
164+
165+
writeStream.once("finish", function () {
166+
fs.utimes(data.dest, data.atime, data.mtime, function (err) {
167+
if (err) {
168+
reject(err);
169+
} else {
170+
if (events) events.onProgress(data.dest);
171+
resolve();
172+
}
109173
});
110174
});
111-
} else {
112-
throw new Error("unsure how to copy this?");
113-
}
175+
}), 4);
114176
}
115177

116178
export async function readFile(loc: string): Promise<string> {

0 commit comments

Comments
 (0)