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

Detect unfulfilled async hook actions and report error on exit #4320

Merged
merged 2 commits into from Jan 4, 2022
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
3 changes: 3 additions & 0 deletions browser/hookActions.ts
@@ -0,0 +1,3 @@
export function addUnresolvedAction(_actionTuple: [string, string, Parameters<any>]): void {}

export function resolveAction(_actionTuple: [string, string, Parameters<any>]): void {}
3 changes: 3 additions & 0 deletions build-plugins/replace-browser-modules.ts
Expand Up @@ -3,6 +3,7 @@ import { Plugin } from 'rollup';

const ID_CRYPTO = path.resolve('src/utils/crypto');
const ID_FS = path.resolve('src/utils/fs');
const ID_HOOKACTIONS = path.resolve('src/utils/hookActions');
const ID_PATH = path.resolve('src/utils/path');
const ID_RESOLVEID = path.resolve('src/utils/resolveId');

Expand All @@ -17,6 +18,8 @@ export default function replaceBrowserModules(): Plugin {
return path.resolve('browser/crypto.ts');
case ID_FS:
return path.resolve('browser/fs.ts');
case ID_HOOKACTIONS:
return path.resolve('browser/hookActions.ts');
case ID_PATH:
return path.resolve('browser/path.ts');
case ID_RESOLVEID:
Expand Down
35 changes: 33 additions & 2 deletions src/utils/PluginDriver.ts
Expand Up @@ -22,6 +22,7 @@ import {
import { FileEmitter } from './FileEmitter';
import { getPluginContext } from './PluginContext';
import { errInputHookInOutputPlugin, error } from './error';
import { addUnresolvedAction, resolveAction } from './hookActions';
import { throwPluginError, warnDeprecatedHooks } from './pluginUtils';

/**
Expand Down Expand Up @@ -314,6 +315,7 @@ export class PluginDriver {
context = hookContext(context, plugin);
}

let action: [string, string, Parameters<any>] | null = null;
return Promise.resolve()
.then(() => {
// permit values allows values to be returned instead of a functional hook
Expand All @@ -322,9 +324,38 @@ export class PluginDriver {
return throwInvalidHookError(hookName, plugin.name);
}
// eslint-disable-next-line @typescript-eslint/ban-types
return (hook as Function).apply(context, args);
const hookResult = (hook as Function).apply(context, args);

if (!hookResult || !hookResult.then) {
// short circuit for non-thenables and non-Promises
return hookResult;
}

// Track pending hook actions to properly error out when
// unfulfilled promises cause rollup to abruptly and confusingly
// exit with a successful 0 return code but without producing any
// output, errors or warnings.
action = [plugin.name, hookName, args];
addUnresolvedAction(action);

// Although it would be more elegant to just return hookResult here
// and put the .then() handler just above the .catch() handler below,
// doing so would subtly change the defacto async event dispatch order
// which at least one test and some plugins in the wild may depend on.
const promise = Promise.resolve(hookResult);
return promise.then(() => {
// action was fulfilled
resolveAction(action as [string, string, Parameters<any>]);
return promise;
});
})
.catch(err => throwPluginError(err, plugin.name, { hook: hookName }));
.catch(err => {
if (action !== null) {
// action considered to be fulfilled since error being handled
resolveAction(action);
}
return throwPluginError(err, plugin.name, { hook: hookName });
});
}

/**
Expand Down
37 changes: 37 additions & 0 deletions src/utils/hookActions.ts
@@ -0,0 +1,37 @@
const unfulfilledActions: Set<[string, string, Parameters<any>]> = new Set();

export function addUnresolvedAction(actionTuple: [string, string, Parameters<any>]): void {
unfulfilledActions.add(actionTuple);
}

export function resolveAction(actionTuple: [string, string, Parameters<any>]): void {
unfulfilledActions.delete(actionTuple);
}

function formatAction([pluginName, hookName, args]: [string, string, Parameters<any>]): string {
let action = `(${pluginName}) ${hookName}`;
const s = JSON.stringify;
switch (hookName) {
case 'resolveId':
action += ` ${s(args[0])} ${s(args[1])}`;
break;
case 'load':
action += ` ${s(args[0])}`;
break;
case 'transform':
action += ` ${s(args[1])}`;
break;
}
return action;
}

process.on('exit', () => {
if (unfulfilledActions.size) {
let err = '[!] Error: unfinished hook action(s) on exit:\n';
for (const action of unfulfilledActions) {
err += formatAction(action) + '\n';
}
console.error('%s', err);
process.exit(1);
}
});
2 changes: 1 addition & 1 deletion test/cli/index.js
Expand Up @@ -35,7 +35,7 @@ runTestSuiteWithSamples(
env: { ...process.env, FORCE_COLOR: '0', ...config.env }
},
(err, code, stderr) => {
if (config.after) config.after();
if (config.after) config.after(err, code, stderr);
if (err && !err.killed) {
if (config.error) {
const shouldContinue = config.error(err);
Expand Down
24 changes: 24 additions & 0 deletions test/cli/samples/unfulfilled-hook-actions/_config.js
@@ -0,0 +1,24 @@
const assert = require('assert');
const { assertIncludes } = require('../../../utils.js');

module.exports = {
description: 'show errors with non-zero exit code for unfulfilled async plugin actions on exit',
command: 'rollup main.js -p ./buggy-plugin.js --silent',
after(err) {
// exit code check has to be here as error(err) is only called upon failure
assert.strictEqual(err && err.code, 1);
},
error(err) {
// do not abort test upon error
return true;
},
stderr(stderr) {
assertIncludes(stderr, '[!] Error: unfinished hook action(s) on exit');

// these unfulfilled async hook actions may occur in random order
assertIncludes(stderr, '(buggy-plugin) resolveId "./c.js" "main.js"');
assertIncludes(stderr, '(buggy-plugin) load "./b.js"');
assertIncludes(stderr, '(buggy-plugin) transform "./a.js"');
assertIncludes(stderr, '(buggy-plugin) moduleParsed');
}
};
Empty file.
1 change: 1 addition & 0 deletions test/cli/samples/unfulfilled-hook-actions/a.js
@@ -0,0 +1 @@
console.log('a');
1 change: 1 addition & 0 deletions test/cli/samples/unfulfilled-hook-actions/b.js
@@ -0,0 +1 @@
console.log('b');
33 changes: 33 additions & 0 deletions test/cli/samples/unfulfilled-hook-actions/buggy-plugin.js
@@ -0,0 +1,33 @@
var path = require('path');

function relative(id) {
if (id[0] != '/') return id;
if (id[0] != '\\') return id;
return './' + path.relative(process.cwd(), id);
}

module.exports = function() {
return {
name: 'buggy-plugin',
resolveId(id) {
if (id.includes('\0')) return;

// this action will never resolve or reject
if (id.endsWith('c.js')) return new Promise(() => {});

return relative(id);
},
load(id) {
// this action will never resolve or reject
if (id.endsWith('b.js')) return new Promise(() => {});
},
transform(code, id) {
// this action will never resolve or reject
if (id.endsWith('a.js')) return new Promise(() => {});
},
moduleParsed(mod) {
// this action will never resolve or reject
if (mod.id.endsWith('d.js')) return new Promise(() => {});
}
};
}
1 change: 1 addition & 0 deletions test/cli/samples/unfulfilled-hook-actions/c.js
@@ -0,0 +1 @@
console.log('c');
1 change: 1 addition & 0 deletions test/cli/samples/unfulfilled-hook-actions/d.js
@@ -0,0 +1 @@
console.log('d');
5 changes: 5 additions & 0 deletions test/cli/samples/unfulfilled-hook-actions/main.js
@@ -0,0 +1,5 @@
import './a.js';
import './b.js';
import './c.js';
import './d.js';
console.log('main');