Skip to content

Commit 838110a

Browse files
authored
Merge pull request #26741 from Microsoft/gulpWatch
Fix overlapping test runs in 'gulp watch'
2 parents d066e1e + 111300c commit 838110a

File tree

6 files changed

+135
-143
lines changed

6 files changed

+135
-143
lines changed

Gulpfile.js

+57-35
Original file line numberDiff line numberDiff line change
@@ -24,10 +24,9 @@ const baselineAccept = require("./scripts/build/baselineAccept");
2424
const cmdLineOptions = require("./scripts/build/options");
2525
const exec = require("./scripts/build/exec");
2626
const browserify = require("./scripts/build/browserify");
27-
const debounce = require("./scripts/build/debounce");
2827
const prepend = require("./scripts/build/prepend");
2928
const { removeSourceMaps } = require("./scripts/build/sourcemaps");
30-
const { CancelSource, CancelError } = require("./scripts/build/cancellation");
29+
const { CancellationTokenSource, CancelError, delay, Semaphore } = require("prex");
3130
const { libraryTargets, generateLibs } = require("./scripts/build/lib");
3231
const { runConsoleTests, cleanTestDirs, writeTestConfigFile, refBaseline, localBaseline, refRwcBaseline, localRwcBaseline } = require("./scripts/build/tests");
3332

@@ -534,57 +533,80 @@ gulp.task(
534533
["watch-diagnostics", "watch-lib"].concat(useCompilerDeps),
535534
() => project.watch(tsserverProject, { typescript: useCompiler }));
536535

537-
gulp.task(
538-
"watch-local",
539-
/*help*/ false,
540-
["watch-lib", "watch-tsc", "watch-services", "watch-server"]);
541-
542536
gulp.task(
543537
"watch-runner",
544538
/*help*/ false,
545539
useCompilerDeps,
546540
() => project.watch(testRunnerProject, { typescript: useCompiler }));
547541

548-
const watchPatterns = [
549-
runJs,
550-
typescriptDts,
551-
tsserverlibraryDts
552-
];
542+
gulp.task(
543+
"watch-local",
544+
"Watches for changes to projects in src/ (but does not execute tests).",
545+
["watch-lib", "watch-tsc", "watch-services", "watch-server", "watch-runner", "watch-lssl"]);
553546

554547
gulp.task(
555548
"watch",
556-
"Watches for changes to the build inputs for built/local/run.js, then executes runtests-parallel.",
549+
"Watches for changes to the build inputs for built/local/run.js, then runs tests.",
557550
["build-rules", "watch-runner", "watch-services", "watch-lssl"],
558551
() => {
559-
/** @type {CancelSource | undefined} */
560-
let runTestsSource;
552+
const sem = new Semaphore(1);
561553

562-
const fn = debounce(() => {
563-
runTests().catch(error => {
564-
if (error instanceof CancelError) {
565-
log.warn("Operation was canceled");
566-
}
567-
else {
568-
log.error(error);
569-
}
570-
});
571-
}, /*timeout*/ 100, { max: 500 });
572-
573-
gulp.watch(watchPatterns, () => project.wait().then(fn));
554+
gulp.watch([runJs, typescriptDts, tsserverlibraryDts], () => {
555+
runTests();
556+
});
574557

575558
// NOTE: gulp.watch is far too slow when watching tests/cases/**/* as it first enumerates *every* file
576559
const testFilePattern = /(\.ts|[\\/]tsconfig\.json)$/;
577560
fs.watch("tests/cases", { recursive: true }, (_, file) => {
578-
if (testFilePattern.test(file)) project.wait().then(fn);
561+
if (testFilePattern.test(file)) runTests();
579562
});
580563

581-
function runTests() {
582-
if (runTestsSource) runTestsSource.cancel();
583-
runTestsSource = new CancelSource();
584-
return cmdLineOptions.tests || cmdLineOptions.failed
585-
? runConsoleTests(runJs, "mocha-fivemat-progress-reporter", /*runInParallel*/ false, /*watchMode*/ true, runTestsSource.token)
586-
: runConsoleTests(runJs, "min", /*runInParallel*/ true, /*watchMode*/ true, runTestsSource.token);
587-
}
564+
async function runTests() {
565+
try {
566+
// Ensure only one instance of the test runner is running at any given time.
567+
if (sem.count > 0) {
568+
await sem.wait();
569+
try {
570+
// Wait for any concurrent recompilations to complete...
571+
try {
572+
await delay(100);
573+
while (project.hasRemainingWork()) {
574+
await project.waitForWorkToComplete();
575+
await delay(500);
576+
}
577+
}
578+
catch (e) {
579+
if (e instanceof CancelError) return;
580+
throw e;
581+
}
582+
583+
// cancel any pending or active test run if a new recompilation is triggered
584+
const source = new CancellationTokenSource();
585+
project.waitForWorkToStart().then(() => {
586+
source.cancel();
587+
});
588+
589+
if (cmdLineOptions.tests || cmdLineOptions.failed) {
590+
await runConsoleTests(runJs, "mocha-fivemat-progress-reporter", /*runInParallel*/ false, /*watchMode*/ true, source.token);
591+
}
592+
else {
593+
await runConsoleTests(runJs, "min", /*runInParallel*/ true, /*watchMode*/ true, source.token);
594+
}
595+
}
596+
finally {
597+
sem.release();
598+
}
599+
}
600+
}
601+
catch (e) {
602+
if (e instanceof CancelError) {
603+
log.warn("Operation was canceled");
604+
}
605+
else {
606+
log.error(e);
607+
}
608+
}
609+
};
588610
});
589611

590612
gulp.task("clean-built", /*help*/ false, [`clean:${diagnosticInformationMapTs}`], () => del(["built"]));

package.json

+1
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,7 @@
8181
"mocha": "latest",
8282
"mocha-fivemat-progress-reporter": "latest",
8383
"plugin-error": "latest",
84+
"prex": "^0.4.3",
8485
"q": "latest",
8586
"remove-internal": "^2.9.2",
8687
"run-sequence": "latest",

scripts/build/cancellation.js

-71
This file was deleted.

scripts/build/exec.js

+17-12
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ const cp = require("child_process");
33
const log = require("fancy-log"); // was `require("gulp-util").log (see https://github.com/gulpjs/gulp-util)
44
const isWin = /^win/.test(process.platform);
55
const chalk = require("./chalk");
6-
const { CancelToken, CancelError } = require("./cancellation");
6+
const { CancellationToken, CancelError } = require("prex");
77

88
module.exports = exec;
99

@@ -15,31 +15,36 @@ module.exports = exec;
1515
*
1616
* @typedef ExecOptions
1717
* @property {boolean} [ignoreExitCode]
18-
* @property {CancelToken} [cancelToken]
18+
* @property {import("prex").CancellationToken} [cancelToken]
1919
*/
2020
function exec(cmd, args, options = {}) {
2121
return /**@type {Promise<{exitCode: number}>}*/(new Promise((resolve, reject) => {
22-
log(`> ${chalk.green(cmd)} ${args.join(" ")}`);
22+
const { ignoreExitCode, cancelToken = CancellationToken.none } = options;
23+
cancelToken.throwIfCancellationRequested();
24+
2325
// TODO (weswig): Update child_process types to add windowsVerbatimArguments to the type definition
2426
const subshellFlag = isWin ? "/c" : "-c";
2527
const command = isWin ? [possiblyQuote(cmd), ...args] : [`${cmd} ${args.join(" ")}`];
26-
const ex = cp.spawn(isWin ? "cmd" : "/bin/sh", [subshellFlag, ...command], { stdio: "inherit", windowsVerbatimArguments: true });
27-
const subscription = options.cancelToken && options.cancelToken.subscribe(() => {
28-
ex.kill("SIGINT");
29-
ex.kill("SIGTERM");
28+
29+
log(`> ${chalk.green(cmd)} ${args.join(" ")}`);
30+
const proc = cp.spawn(isWin ? "cmd" : "/bin/sh", [subshellFlag, ...command], { stdio: "inherit", windowsVerbatimArguments: true });
31+
const registration = cancelToken.register(() => {
32+
log(`${chalk.red("killing")} '${chalk.green(cmd)} ${args.join(" ")}'...`);
33+
proc.kill("SIGINT");
34+
proc.kill("SIGTERM");
3035
reject(new CancelError());
3136
});
32-
ex.on("exit", exitCode => {
33-
subscription && subscription.unsubscribe();
34-
if (exitCode === 0 || options.ignoreExitCode) {
37+
proc.on("exit", exitCode => {
38+
registration.unregister();
39+
if (exitCode === 0 || ignoreExitCode) {
3540
resolve({ exitCode });
3641
}
3742
else {
3843
reject(new Error(`Process exited with code: ${exitCode}`));
3944
}
4045
});
41-
ex.on("error", error => {
42-
subscription && subscription.unsubscribe();
46+
proc.on("error", error => {
47+
registration.unregister();
4348
reject(error);
4449
});
4550
}));

scripts/build/project.js

+56-23
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ const path = require("path");
33
const fs = require("fs");
44
const gulp = require("./gulp");
55
const gulpif = require("gulp-if");
6+
const log = require("fancy-log"); // was `require("gulp-util").log (see https://github.com/gulpjs/gulp-util)
7+
const chalk = require("./chalk");
68
const sourcemaps = require("gulp-sourcemaps");
79
const merge2 = require("merge2");
810
const tsc = require("gulp-typescript");
@@ -12,23 +14,52 @@ const ts = require("../../lib/typescript");
1214
const del = require("del");
1315
const needsUpdate = require("./needsUpdate");
1416
const mkdirp = require("./mkdirp");
17+
const prettyTime = require("pretty-hrtime");
1518
const { reportDiagnostics } = require("./diagnostics");
19+
const { CountdownEvent, ManualResetEvent } = require("prex");
20+
21+
const workStartedEvent = new ManualResetEvent();
22+
const countdown = new CountdownEvent(0);
1623

1724
class CompilationGulp extends gulp.Gulp {
1825
/**
1926
* @param {boolean} [verbose]
2027
*/
2128
fork(verbose) {
2229
const child = new ForkedGulp(this.tasks);
23-
if (verbose) {
24-
child.on("task_start", e => gulp.emit("task_start", e));
25-
child.on("task_stop", e => gulp.emit("task_stop", e));
26-
child.on("task_err", e => gulp.emit("task_err", e));
27-
child.on("task_not_found", e => gulp.emit("task_not_found", e));
28-
child.on("task_recursion", e => gulp.emit("task_recursion", e));
29-
}
30+
child.on("task_start", e => {
31+
if (countdown.remainingCount === 0) {
32+
countdown.reset(1);
33+
workStartedEvent.set();
34+
workStartedEvent.reset();
35+
}
36+
else {
37+
countdown.add();
38+
}
39+
if (verbose) {
40+
log('Starting', `'${chalk.cyan(e.task)}' ${chalk.gray(`(${countdown.remainingCount} remaining)`)}...`);
41+
}
42+
});
43+
child.on("task_stop", e => {
44+
countdown.signal();
45+
if (verbose) {
46+
log('Finished', `'${chalk.cyan(e.task)}' after ${chalk.magenta(prettyTime(/** @type {*}*/(e).hrDuration))} ${chalk.gray(`(${countdown.remainingCount} remaining)`)}`);
47+
}
48+
});
49+
child.on("task_err", e => {
50+
countdown.signal();
51+
if (verbose) {
52+
log(`'${chalk.cyan(e.task)}' ${chalk.red("errored after")} ${chalk.magenta(prettyTime(/** @type {*}*/(e).hrDuration))} ${chalk.gray(`(${countdown.remainingCount} remaining)`)}`);
53+
log(e.err ? e.err.stack : e.message);
54+
}
55+
});
3056
return child;
3157
}
58+
59+
// @ts-ignore
60+
start() {
61+
throw new Error("Not supported, use fork.");
62+
}
3263
}
3364

3465
class ForkedGulp extends gulp.Gulp {
@@ -211,24 +242,26 @@ exports.flatten = flatten;
211242

212243
/**
213244
* Returns a Promise that resolves when all pending build tasks have completed
245+
* @param {import("prex").CancellationToken} [token]
214246
*/
215-
function wait() {
216-
return new Promise(resolve => {
217-
if (compilationGulp.allDone()) {
218-
resolve();
219-
}
220-
else {
221-
const onDone = () => {
222-
compilationGulp.removeListener("onDone", onDone);
223-
compilationGulp.removeListener("err", onDone);
224-
resolve();
225-
};
226-
compilationGulp.on("stop", onDone);
227-
compilationGulp.on("err", onDone);
228-
}
229-
});
247+
function waitForWorkToComplete(token) {
248+
return countdown.wait(token);
249+
}
250+
exports.waitForWorkToComplete = waitForWorkToComplete;
251+
252+
/**
253+
* Returns a Promise that resolves when all pending build tasks have completed
254+
* @param {import("prex").CancellationToken} [token]
255+
*/
256+
function waitForWorkToStart(token) {
257+
return workStartedEvent.wait(token);
258+
}
259+
exports.waitForWorkToStart = waitForWorkToStart;
260+
261+
function getRemainingWork() {
262+
return countdown.remainingCount > 0;
230263
}
231-
exports.wait = wait;
264+
exports.hasRemainingWork = getRemainingWork;
232265

233266
/**
234267
* Resolve a TypeScript specifier into a fully-qualified module specifier and any requisite dependencies.

0 commit comments

Comments
 (0)