Thanks to visit codestin.com
Credit goes to github.com

Skip to content
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.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Next Next commit
Detect unfulfilled async hook actions and report error on exit
* Better user experience with buggy plugins that previously caused
  rollup to exit abruptly without any output, error or warning,
  and a zero process exit code incorrectly indicating success.
  • Loading branch information
kzc committed Dec 30, 2021
commit bb96097fc6768c83f58d1e4876493c666e153757
3 changes: 3 additions & 0 deletions browser/hookActions.ts
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
console.log('a');
1 change: 1 addition & 0 deletions test/cli/samples/unfulfilled-hook-actions/b.js
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
console.log('b');
33 changes: 33 additions & 0 deletions test/cli/samples/unfulfilled-hook-actions/buggy-plugin.js
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
console.log('c');
1 change: 1 addition & 0 deletions test/cli/samples/unfulfilled-hook-actions/d.js
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
console.log('d');
5 changes: 5 additions & 0 deletions test/cli/samples/unfulfilled-hook-actions/main.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import './a.js';
import './b.js';
import './c.js';
import './d.js';
console.log('main');