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

[eslint] add rule to forbid async forEach bodies #111637

Merged
merged 15 commits into from
Sep 14, 2021
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/elastic-eslint-config-kibana/.eslintrc.js
Original file line number Diff line number Diff line change
Expand Up @@ -92,5 +92,6 @@ module.exports = {
],

'@kbn/eslint/no_async_promise_body': 'error',
'@kbn/eslint/no_async_foreach': 'error',
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I believe we should expand the rule to cover other expressions, like this one #98463 (comment)
I'm not sure whether no-floating-promises implements the same check, but we can give it a try.

Copy link
Contributor Author

@spalger spalger Sep 13, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, I'll look at a similar solution for that. I don't want to overload this rule though and have a special error message for that scenario.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't want to overload this rule though and have a special error message for that scenario.

Would you suggest implementing a custom rule for every use-case when (...args: any[]) => Promise<any> is used instead of (...args: any[]) => void?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I do think that having custom error messages for each situation would be nice, though I'm not convinced there are that many scenarios right now. It's possibly a bigger more wide issue than I'm thinking though.

},
};
1 change: 1 addition & 0 deletions packages/kbn-eslint-plugin-eslint/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,5 +14,6 @@ module.exports = {
module_migration: require('./rules/module_migration'),
no_export_all: require('./rules/no_export_all'),
no_async_promise_body: require('./rules/no_async_promise_body'),
no_async_foreach: require('./rules/no_async_foreach'),
},
};
62 changes: 62 additions & 0 deletions packages/kbn-eslint-plugin-eslint/rules/no_async_foreach.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/

const tsEstree = require('@typescript-eslint/typescript-estree');
const esTypes = tsEstree.AST_NODE_TYPES;

/** @typedef {import("eslint").Rule.RuleModule} Rule */
/** @typedef {import("@typescript-eslint/typescript-estree").TSESTree.Node} Node */
/** @typedef {import("@typescript-eslint/typescript-estree").TSESTree.CallExpression} CallExpression */
/** @typedef {import("@typescript-eslint/typescript-estree").TSESTree.FunctionExpression} FunctionExpression */
/** @typedef {import("@typescript-eslint/typescript-estree").TSESTree.ArrowFunctionExpression} ArrowFunctionExpression */
/** @typedef {import("eslint").Rule.RuleFixer} Fixer */

const ERROR_MSG =
'Passing an async function to .forEach() prevents promise rejections from being handled. Use asyncForEach() or similar helper from "@kbn/std" instead.';

/**
* @param {Node} node
* @returns {node is ArrowFunctionExpression | FunctionExpression}
*/
const isFunc = (node) =>
node.type === esTypes.ArrowFunctionExpression || node.type === esTypes.FunctionExpression;

/**
* @param {any} context
* @param {CallExpression} node
*/
const isAsyncForEachCall = (node) => {
return (
node.callee.type === esTypes.MemberExpression &&
node.callee.property.type === esTypes.Identifier &&
node.callee.property.name === 'forEach' &&
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

unfortunately, it doesn't cover cases when a promise is returned without an async keyword 😞

Copy link
Contributor Author

@spalger spalger Sep 13, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, in that case we're creating a promise which can be handled, so I feel like it's less of an issue.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, in that case we're creating a promise which can be handled

Could you elaborate on it? Sorry, I'm not follow who handles a promise in this case 😞

async function doAsync () {}
[].forEach(doAsync)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah, I figured you were describing:

array.forEach(() => new Promise((resolve) => ...))

There's a chance we could improve this to use types for locally defined functions, but the more research I do on other things the more I feel like we need to start providing ESLint with full types... Which has a lot of complications.

node.arguments.length >= 1 &&
isFunc(node.arguments[0]) &&
node.arguments[0].async
);
};

/** @type {Rule} */
module.exports = {
meta: {
fixable: 'code',
schema: [],
},
create: (context) => ({
CallExpression(_) {
const node = /** @type {CallExpression} */ (_);

if (isAsyncForEachCall(node)) {
context.report({
message: ERROR_MSG,
loc: node.arguments[0].loc,
});
}
},
}),
};
72 changes: 72 additions & 0 deletions packages/kbn-eslint-plugin-eslint/rules/no_async_foreach.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/

const { RuleTester } = require('eslint');
const rule = require('./no_async_foreach');
const dedent = require('dedent');

const ruleTester = new RuleTester({
parser: require.resolve('@typescript-eslint/parser'),
parserOptions: {
sourceType: 'module',
ecmaVersion: 2018,
ecmaFeatures: {
jsx: true,
},
},
});

ruleTester.run('@kbn/eslint/no_async_foreach', rule, {
valid: [
{
code: dedent`
array.forEach((a) => {
b(a)
})
`,
},
{
code: dedent`
array.forEach(function (a) {
b(a)
})
`,
},
],

invalid: [
{
code: dedent`
array.forEach(async (a) => {
await b(a)
})
`,
errors: [
{
line: 1,
message:
'Passing an async function to .forEach() prevents promise rejections from being handled. Use asyncForEach() or similar helper from "@kbn/std" instead.',
},
],
},
{
code: dedent`
array.forEach(async function (a) {
await b(a)
})
`,
errors: [
{
line: 1,
message:
'Passing an async function to .forEach() prevents promise rejections from being handled. Use asyncForEach() or similar helper from "@kbn/std" instead.',
},
],
},
],
});
5 changes: 4 additions & 1 deletion packages/kbn-std/BUILD.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,10 @@ SOURCE_FILES = glob(
[
"src/**/*.ts",
],
exclude = ["**/*.test.*"],
exclude = [
"**/*.test.*",
"**/test_helpers.ts",
],
)

SRCS = SOURCE_FILES
Expand Down
8 changes: 8 additions & 0 deletions packages/kbn-std/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,3 +18,11 @@ export { unset } from './unset';
export { getFlattenedObject } from './get_flattened_object';
export { ensureNoUnsafeProperties } from './ensure_no_unsafe_properties';
export * from './rxjs_7';
export {
map$,
mapWithLimit$,
asyncMap,
spalger marked this conversation as resolved.
Show resolved Hide resolved
asyncMapWithLimit,
asyncForEach,
asyncForEachWithLimit,
} from './iteration';
81 changes: 81 additions & 0 deletions packages/kbn-std/src/iteration/for_each.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/

import * as Rx from 'rxjs';

import { asyncForEach, asyncForEachWithLimit } from './for_each';
import { list, sleep } from './test_helpers';

jest.mock('./observable');
const mockMapWithLimit$: jest.Mock = jest.requireMock('./observable').mapWithLimit$;

beforeEach(() => {
jest.clearAllMocks();
});

describe('asyncForEachWithLimit', () => {
it('calls mapWithLimit$ and resolves with undefined when it completes', async () => {
const iter = list(10);
const limit = 5;
const fn = jest.fn();

const result$ = new Rx.Subject();
mockMapWithLimit$.mockImplementation(() => result$);
const promise = asyncForEachWithLimit(iter, limit, fn);

let resolved = false;
promise.then(() => (resolved = true));

expect(mockMapWithLimit$).toHaveBeenCalledTimes(1);
expect(mockMapWithLimit$).toHaveBeenCalledWith(iter, limit, fn);

expect(resolved).toBe(false);
result$.next(1);
result$.next(2);
result$.next(3);

await sleep(10);
expect(resolved).toBe(false);

result$.complete();
await expect(promise).resolves.toBe(undefined);
});

it('resolves when iterator is empty', async () => {
mockMapWithLimit$.mockImplementation((x) => Rx.from(x));
await expect(asyncForEachWithLimit([], 100, async () => 'foo')).resolves.toBe(undefined);
});
});

describe('asyncForEach', () => {
it('calls mapWithLimit$ without limit and resolves with undefined when it completes', async () => {
spalger marked this conversation as resolved.
Show resolved Hide resolved
const iter = list(10);
const fn = jest.fn();

const result$ = new Rx.Subject();
mockMapWithLimit$.mockImplementation(() => result$);
const promise = asyncForEach(iter, fn);

let resolved = false;
promise.then(() => (resolved = true));

expect(mockMapWithLimit$).toHaveBeenCalledTimes(1);
expect(mockMapWithLimit$).toHaveBeenCalledWith(iter, Infinity, fn);

expect(resolved).toBe(false);
result$.next(1);
result$.next(2);
result$.next(3);

await sleep(10);
expect(resolved).toBe(false);

result$.complete();
await expect(promise).resolves.toBe(undefined);
});
});
44 changes: 44 additions & 0 deletions packages/kbn-std/src/iteration/for_each.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/

import { defaultIfEmpty } from 'rxjs/operators';

import { lastValueFrom } from '../rxjs_7';
import { mapWithLimit$ } from './observable';
import { IterableInput, AsyncMapFn } from './types';

/**
* Creates a promise which resolves with `undefined` after calling `fn` for each
* item in `iterable`. `fn` can return either a Promise or Observable. If `fn`
* returns observables then they will properly abort if an error occurs.
*
* @param iterable Items to iterate
* @param fn Function to call for each item
*/
export async function asyncForEach<T>(iterable: IterableInput<T>, fn: AsyncMapFn<T, any>) {
await lastValueFrom(mapWithLimit$(iterable, Infinity, fn).pipe(defaultIfEmpty()));
}

/**
* Creates a promise which resolves with `undefined` after calling `fn` for each
* item in `iterable`. `fn` can return either a Promise or Observable. If `fn`
* returns observables then they will properly abort if an error occurs.
*
* The number of concurrent executions of `fn` is limited by `limit`.
*
* @param iterable Items to iterate
* @param limit Maximum number of operations to run in parallel
* @param fn Function to call for each item
*/
export async function asyncForEachWithLimit<T>(
iterable: IterableInput<T>,
limit: number,
spalger marked this conversation as resolved.
Show resolved Hide resolved
fn: AsyncMapFn<T, any>
) {
await lastValueFrom(mapWithLimit$(iterable, limit, fn).pipe(defaultIfEmpty()));
}
11 changes: 11 additions & 0 deletions packages/kbn-std/src/iteration/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/

export * from './observable';
export * from './for_each';
export * from './map';
82 changes: 82 additions & 0 deletions packages/kbn-std/src/iteration/map.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/

import * as Rx from 'rxjs';
import { mapTo } from 'rxjs/operators';

import { asyncMap, asyncMapWithLimit } from './map';
import { list } from './test_helpers';

jest.mock('./observable');
const mapWithLimit$: jest.Mock = jest.requireMock('./observable').mapWithLimit$;
mapWithLimit$.mockImplementation(jest.requireActual('./observable').mapWithLimit$);

beforeEach(() => {
jest.clearAllMocks();
});

describe('asyncMapWithLimit', () => {
it('calls mapWithLimit$ and resolves with properly sorted results', async () => {
const iter = list(10);
const limit = 5;
const fn = jest.fn((n) => (n % 2 ? Rx.timer(n) : Rx.timer(n * 4)).pipe(mapTo(n)));
const result = await asyncMapWithLimit(iter, limit, fn);

expect(result).toMatchInlineSnapshot(`
Array [
0,
1,
2,
3,
4,
5,
6,
7,
8,
9,
]
`);

expect(mapWithLimit$).toHaveBeenCalledTimes(1);
expect(mapWithLimit$).toHaveBeenCalledWith(iter, limit, expect.any(Function));
});

it.each([
[list(0), []] as const,
[list(1), ['foo']] as const,
[list(2), ['foo', 'foo']] as const,
])('resolves when iterator is %p', async (input, output) => {
await expect(asyncMapWithLimit(input, 100, async () => 'foo')).resolves.toEqual(output);
});
});

describe('asyncMap', () => {
it('calls mapWithLimit$ without limit and resolves with undefined when it completes', async () => {
const iter = list(10);
const fn = jest.fn((n) => (n % 2 ? Rx.timer(n) : Rx.timer(n * 4)).pipe(mapTo(n)));
const result = await asyncMap(iter, fn);

expect(result).toMatchInlineSnapshot(`
Array [
0,
1,
2,
3,
4,
5,
6,
7,
8,
9,
]
`);

expect(mapWithLimit$).toHaveBeenCalledTimes(1);
expect(mapWithLimit$).toHaveBeenCalledWith(iter, Infinity, expect.any(Function));
});
});
Loading