@noctcore/eslint-plugin-async-safety 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +61 -0
- package/dist/index.cjs +847 -0
- package/dist/index.d.cts +59 -0
- package/dist/index.d.ts +59 -0
- package/dist/index.js +819 -0
- package/docs/rules/forward-abort-signal.md +46 -0
- package/docs/rules/no-concurrent-shared-mutation.md +48 -0
- package/docs/rules/no-shared-mutable-module-state.md +62 -0
- package/docs/rules/prefer-parallel-awaits.md +45 -0
- package/docs/rules/require-fetch-timeout.md +55 -0
- package/package.json +67 -0
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# `noctcore-async-safety/forward-abort-signal`
|
|
2
|
+
|
|
3
|
+
> A function that accepts an `AbortSignal` but never forwards it to the work it awaits leaves that work uncancellable.
|
|
4
|
+
|
|
5
|
+
## Why
|
|
6
|
+
|
|
7
|
+
Threading an `AbortSignal` through a call graph is only useful if every layer passes it down. A function that
|
|
8
|
+
takes a `signal`, maybe checks `signal.aborted`, but then `await`s a `fetch`/call without passing the signal
|
|
9
|
+
along has a dead parameter: callers think they can cancel, but the actual I/O ignores them. The cancel never
|
|
10
|
+
reaches the socket.
|
|
11
|
+
|
|
12
|
+
## What it flags
|
|
13
|
+
|
|
14
|
+
A function (declaration, expression, or arrow) that:
|
|
15
|
+
|
|
16
|
+
- accepts an AbortSignal-shaped parameter — named `signal`, typed `AbortSignal`, or the destructured `{ signal }` form; **and**
|
|
17
|
+
- contains an awaited call or a `fetch(...)` (something that could have received the signal); **and**
|
|
18
|
+
- never forwards the signal — every use is a member-access check (`signal.aborted`, `signal.throwIfAborted()`), or it is unused.
|
|
19
|
+
|
|
20
|
+
"Forwarding" is deliberately generous: passing the signal as an argument, into an options object, assigning it,
|
|
21
|
+
or returning it all count — so the rule errs toward silence rather than false positives.
|
|
22
|
+
|
|
23
|
+
```ts
|
|
24
|
+
// ✗ signal accepted, fetch left uncancellable
|
|
25
|
+
async function load(url: string, signal: AbortSignal) {
|
|
26
|
+
return await fetch(url);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// ✓ signal forwarded
|
|
30
|
+
async function load(url: string, signal: AbortSignal) {
|
|
31
|
+
return await fetch(url, { signal });
|
|
32
|
+
}
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
A function with no awaited call or `fetch` (e.g. a pure `while (!signal.aborted)` polling loop) is never flagged
|
|
36
|
+
— there is nothing to forward to.
|
|
37
|
+
|
|
38
|
+
## Options
|
|
39
|
+
|
|
40
|
+
None.
|
|
41
|
+
|
|
42
|
+
## When not to use it
|
|
43
|
+
|
|
44
|
+
If you deliberately accept a signal only to poll `.aborted` in a compute loop with no downstream call, this rule
|
|
45
|
+
will still fire when that loop `await`s something (a `sleep`, say) that cannot accept a signal. Disable it inline
|
|
46
|
+
for those functions.
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# `noctcore-async-safety/no-concurrent-shared-mutation`
|
|
2
|
+
|
|
3
|
+
> A read-modify-write of an outer-scope binding inside a concurrent `Promise.all(arr.map(async …))` callback can lose updates.
|
|
4
|
+
|
|
5
|
+
## Why
|
|
6
|
+
|
|
7
|
+
`Promise.all(arr.map(async …))` starts every callback before any of them resolve, so their `await` points
|
|
8
|
+
interleave. A read-modify-write of a shared outer binding — `total = total + await amount()`, `total += …`,
|
|
9
|
+
`count++` after an await — can have two iterations read the same value and one clobber the other. The classic
|
|
10
|
+
lost update: the final total is wrong, non-deterministically.
|
|
11
|
+
|
|
12
|
+
## What it flags
|
|
13
|
+
|
|
14
|
+
Inside an `async` callback passed to `.map`/`.flatMap`/`.forEach` whose result is wrapped in `Promise.all` /
|
|
15
|
+
`Promise.allSettled`, and which contains at least one `await`:
|
|
16
|
+
|
|
17
|
+
- a compound assignment to an outer binding (`x += …`, `x *= …`, …);
|
|
18
|
+
- a self-referential assignment (`x = x + …`);
|
|
19
|
+
- an update expression on an outer binding (`x++`, `--x`).
|
|
20
|
+
|
|
21
|
+
```ts
|
|
22
|
+
// ✗ lost updates
|
|
23
|
+
let total = 0;
|
|
24
|
+
await Promise.all(items.map(async (item) => {
|
|
25
|
+
total += await priceOf(item);
|
|
26
|
+
}));
|
|
27
|
+
|
|
28
|
+
// ✓ collect, then reduce
|
|
29
|
+
const prices = await Promise.all(items.map((item) => priceOf(item)));
|
|
30
|
+
const total = prices.reduce((a, b) => a + b, 0);
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
Deliberately conservative to distinguish real races from safe patterns:
|
|
34
|
+
|
|
35
|
+
- `arr.push(…)`, `map.set(…)`, and distinct-index writes (`results[i] = …`) are **not** flagged — they are
|
|
36
|
+
order-tolerant, not lost updates;
|
|
37
|
+
- a plain overwrite (`x = await f()`, no self-reference) is **not** flagged — that is last-write-wins, not a read-modify-write;
|
|
38
|
+
- a binding declared inside the callback is local per iteration, so it is never flagged;
|
|
39
|
+
- the callback must be `async` and contain an `await` — a synchronous callback has no interleaving point.
|
|
40
|
+
|
|
41
|
+
## Options
|
|
42
|
+
|
|
43
|
+
None.
|
|
44
|
+
|
|
45
|
+
## When not to use it
|
|
46
|
+
|
|
47
|
+
If you intentionally accumulate into shared state and have externally serialized the callbacks (e.g. a mutex, or
|
|
48
|
+
a concurrency limit of 1), this rule's warning is a false positive — disable it inline for that block.
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# `noctcore-async-safety/no-shared-mutable-module-state`
|
|
2
|
+
|
|
3
|
+
> A module-scoped mutable binding written from an exported async/handler function is shared across concurrent requests.
|
|
4
|
+
|
|
5
|
+
## Why
|
|
6
|
+
|
|
7
|
+
On a server, module scope is process-wide: every concurrent request runs against the same module-level
|
|
8
|
+
variables. A `let` counter or a `const cache = new Map()` written inside a request handler is not per-request
|
|
9
|
+
state — request B observes and overwrites what request A left behind. This leaks data across users and produces
|
|
10
|
+
races that never appear in single-request local testing.
|
|
11
|
+
|
|
12
|
+
## What it flags
|
|
13
|
+
|
|
14
|
+
Only when the file matches an `include` glob (see Options — the rule is **off by default**), a write to a
|
|
15
|
+
module-scoped mutable binding performed inside an **exported async function** or a **handler-named export**:
|
|
16
|
+
|
|
17
|
+
- a module-level `let`/`var` reassigned (`x = …`, `x += …`, `x++`); or
|
|
18
|
+
- a module-level mutable container `const` (`[]`, `{}`, `new Map()`/`Set()`/`WeakMap()`/`WeakSet()`) mutated
|
|
19
|
+
(`c.push(…)`, `c.set(…)`, `c[i] = …`, `c.prop = …`).
|
|
20
|
+
|
|
21
|
+
Exported functions qualify when they are `async` or named like a handler (`GET`/`POST`/…, `loader`, `action`,
|
|
22
|
+
`handler`, `*Handler`, `middleware`).
|
|
23
|
+
|
|
24
|
+
```ts
|
|
25
|
+
// server.ts (include: ['**/server/**'])
|
|
26
|
+
|
|
27
|
+
// ✗ shared across requests
|
|
28
|
+
let requestCount = 0;
|
|
29
|
+
export async function GET() {
|
|
30
|
+
requestCount += 1;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// ✓ per-request
|
|
34
|
+
export async function GET() {
|
|
35
|
+
const requestCount = 1;
|
|
36
|
+
}
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
The lazy-initialization idioms `x ??= …` and `x ||= …` are always skipped (the common singleton/memoization
|
|
40
|
+
pattern), and names in `allow` are exempt.
|
|
41
|
+
|
|
42
|
+
## Options
|
|
43
|
+
|
|
44
|
+
| Option | Type | Default | Meaning |
|
|
45
|
+
| --- | --- | --- | --- |
|
|
46
|
+
| `include` | `string[]` (globs) | `[]` | Glob patterns for server files to arm the rule on. Empty = the rule does nothing. |
|
|
47
|
+
| `allow` | `string[]` | `[]` | Binding names to exempt (intentional singletons / process-wide caches). |
|
|
48
|
+
|
|
49
|
+
Globs support `**` (any run including `/`), `*` (any run except `/`), `?`, and a leading `**/` that also matches
|
|
50
|
+
zero leading segments.
|
|
51
|
+
|
|
52
|
+
```js
|
|
53
|
+
'noctcore-async-safety/no-shared-mutable-module-state': [
|
|
54
|
+
'error',
|
|
55
|
+
{ include: ['**/server/**', '**/*.server.ts'], allow: ['metricsRegistry'] },
|
|
56
|
+
]
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
## When not to use it
|
|
60
|
+
|
|
61
|
+
Client-side modules legitimately hold shared mutable state (caches, stores, singletons). Keep `include` scoped
|
|
62
|
+
to server code so those are never touched.
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# `noctcore-async-safety/prefer-parallel-awaits`
|
|
2
|
+
|
|
3
|
+
> Consecutive independent awaits run sequentially — they could run concurrently with `Promise.all`. 💡
|
|
4
|
+
|
|
5
|
+
## Why
|
|
6
|
+
|
|
7
|
+
`const a = await getUser(); const b = await getFeed();` waits for `getUser` to finish before `getFeed` even
|
|
8
|
+
starts. When the two have no data dependency, that is latency added for nothing: `await Promise.all([...])`
|
|
9
|
+
runs them concurrently and finishes in the time of the slower one.
|
|
10
|
+
|
|
11
|
+
## What it flags
|
|
12
|
+
|
|
13
|
+
A run of two or more **consecutive** statements of the exact shape `const <id> = await <call>()`, where:
|
|
14
|
+
|
|
15
|
+
- every awaited expression is a single flat call whose arguments hide no nested call, `await`, or assignment
|
|
16
|
+
(so side-effect ordering stays out of scope);
|
|
17
|
+
- the callee does not look like a mutation (`save*`, `create*`, `send*`, `commit*`, `push*`, …) — sequencing
|
|
18
|
+
writes is usually intentional;
|
|
19
|
+
- no later statement references an earlier statement's binding (no data dependency).
|
|
20
|
+
|
|
21
|
+
```ts
|
|
22
|
+
// ✗ sequential, but independent
|
|
23
|
+
const user = await getUser();
|
|
24
|
+
const feed = await getFeed();
|
|
25
|
+
|
|
26
|
+
// ✓ concurrent
|
|
27
|
+
const [user, feed] = await Promise.all([getUser(), getFeed()]);
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
The rule is intentionally conservative — a data dependency, a mutation-looking callee, a `let`, a nested call
|
|
31
|
+
argument, or any non-await statement between them all suppress it.
|
|
32
|
+
|
|
33
|
+
## Suggestion
|
|
34
|
+
|
|
35
|
+
Reports offer an editor suggestion (never an autofix, since parallelizing also changes rejection timing) that
|
|
36
|
+
rewrites the run into a single `const [a, b] = await Promise.all([...])`.
|
|
37
|
+
|
|
38
|
+
## Options
|
|
39
|
+
|
|
40
|
+
None.
|
|
41
|
+
|
|
42
|
+
## When not to use it
|
|
43
|
+
|
|
44
|
+
If your consecutive awaits carry side effects whose ordering matters but that this rule cannot see (e.g. reads
|
|
45
|
+
that mutate a stream), disable it — the suggestion assumes the awaited reads are independent.
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# `noctcore-async-safety/require-fetch-timeout`
|
|
2
|
+
|
|
3
|
+
> A `fetch` (or configured wrapper) call must carry a cancellation signal or timeout — an unbounded request can hang forever. 💡
|
|
4
|
+
|
|
5
|
+
## Why
|
|
6
|
+
|
|
7
|
+
`fetch` has no default timeout. A hung TCP connection or a server that accepts but never responds leaves the
|
|
8
|
+
promise pending indefinitely, tying up a request slot, a connection, and any resources awaiting it. Passing
|
|
9
|
+
`signal: AbortSignal.timeout(ms)` (or an equivalent `timeout` option on a wrapper) bounds the wait and makes
|
|
10
|
+
the failure observable.
|
|
11
|
+
|
|
12
|
+
## What it flags
|
|
13
|
+
|
|
14
|
+
A call to global `fetch` — or any wrapper named in the `callees` option (e.g. `undici.request`, `axios`) —
|
|
15
|
+
whose options carry neither a `signal` nor a `timeout`.
|
|
16
|
+
|
|
17
|
+
The rule is purely syntactic and stays silent whenever it cannot see the arguments:
|
|
18
|
+
|
|
19
|
+
- a spread argument (`fetch(url, ...opts)`) or a `...spread` inside the options object — opaque, skipped;
|
|
20
|
+
- an options slot that is an identifier/call/member (`fetch(url, opts)`) — that bag may already set a signal, skipped;
|
|
21
|
+
- a single non-string argument (`fetch(request)`) — could be a `Request` carrying its own signal, skipped.
|
|
22
|
+
|
|
23
|
+
It reports only when the arguments are plainly signal-free: a bare string/template URL, or a visible options
|
|
24
|
+
object literal with neither key.
|
|
25
|
+
|
|
26
|
+
```ts
|
|
27
|
+
// ✗ no timeout — can hang forever
|
|
28
|
+
await fetch('https://api.example.com/data');
|
|
29
|
+
await fetch(url, { method: 'POST' });
|
|
30
|
+
|
|
31
|
+
// ✓ bounded
|
|
32
|
+
await fetch('https://api.example.com/data', { signal: AbortSignal.timeout(10000) });
|
|
33
|
+
await fetch(url, { method: 'POST', signal: controller.signal });
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
## Suggestion
|
|
37
|
+
|
|
38
|
+
Reports offer an editor suggestion (not an autofix) that inserts `signal: AbortSignal.timeout(<defaultTimeoutMs>)`
|
|
39
|
+
into the options object, creating one if needed.
|
|
40
|
+
|
|
41
|
+
## Options
|
|
42
|
+
|
|
43
|
+
| Option | Type | Default | Meaning |
|
|
44
|
+
| --- | --- | --- | --- |
|
|
45
|
+
| `callees` | `string[]` | `[]` | Additional callee texts to check, matched literally (`'axios'`, `'undici.request'`). `fetch` is always checked. |
|
|
46
|
+
| `defaultTimeoutMs` | `integer` (≥1) | `10000` | The timeout value used in the suggestion. |
|
|
47
|
+
|
|
48
|
+
```js
|
|
49
|
+
'noctcore-async-safety/require-fetch-timeout': ['error', { callees: ['undici.request'], defaultTimeoutMs: 5000 }]
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
## When not to use it
|
|
53
|
+
|
|
54
|
+
If your `fetch` wrapper enforces a timeout centrally (an interceptor, a default `AbortSignal`), the per-call
|
|
55
|
+
signal is redundant — leave `callees` at its default so only bare `fetch` is checked, or disable the rule.
|
package/package.json
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@noctcore/eslint-plugin-async-safety",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "ESLint rules for async correctness: fetch timeouts, AbortSignal forwarding, and shared-state / concurrency races.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"main": "./dist/index.cjs",
|
|
8
|
+
"module": "./dist/index.js",
|
|
9
|
+
"types": "./dist/index.d.ts",
|
|
10
|
+
"exports": {
|
|
11
|
+
".": {
|
|
12
|
+
"types": "./dist/index.d.ts",
|
|
13
|
+
"import": "./dist/index.js",
|
|
14
|
+
"require": "./dist/index.cjs"
|
|
15
|
+
},
|
|
16
|
+
"./package.json": "./package.json"
|
|
17
|
+
},
|
|
18
|
+
"files": [
|
|
19
|
+
"dist",
|
|
20
|
+
"docs",
|
|
21
|
+
"README.md"
|
|
22
|
+
],
|
|
23
|
+
"sideEffects": false,
|
|
24
|
+
"keywords": [
|
|
25
|
+
"eslint",
|
|
26
|
+
"eslintplugin",
|
|
27
|
+
"eslint-plugin",
|
|
28
|
+
"async",
|
|
29
|
+
"abortsignal",
|
|
30
|
+
"fetch",
|
|
31
|
+
"concurrency",
|
|
32
|
+
"noctcore"
|
|
33
|
+
],
|
|
34
|
+
"publishConfig": {
|
|
35
|
+
"access": "public",
|
|
36
|
+
"provenance": true
|
|
37
|
+
},
|
|
38
|
+
"repository": {
|
|
39
|
+
"type": "git",
|
|
40
|
+
"url": "git+https://github.com/noctcore/eslint-plugins.git",
|
|
41
|
+
"directory": "packages/eslint-plugin-async-safety"
|
|
42
|
+
},
|
|
43
|
+
"homepage": "https://github.com/noctcore/eslint-plugins/tree/main/packages/eslint-plugin-async-safety",
|
|
44
|
+
"bugs": "https://github.com/noctcore/eslint-plugins/issues",
|
|
45
|
+
"scripts": {
|
|
46
|
+
"build": "tsup src/index.ts --format esm,cjs --dts --clean",
|
|
47
|
+
"typecheck": "tsc --noEmit",
|
|
48
|
+
"test": "vitest run"
|
|
49
|
+
},
|
|
50
|
+
"dependencies": {
|
|
51
|
+
"@noctcore/eslint-utils": "^0.1.0",
|
|
52
|
+
"@typescript-eslint/utils": "^8.61.1"
|
|
53
|
+
},
|
|
54
|
+
"peerDependencies": {
|
|
55
|
+
"eslint": ">=9.0.0",
|
|
56
|
+
"typescript": ">=5.0.0"
|
|
57
|
+
},
|
|
58
|
+
"devDependencies": {
|
|
59
|
+
"@noctcore/eslint-test-utils": "workspace:*",
|
|
60
|
+
"@types/node": "^22.0.0",
|
|
61
|
+
"@typescript-eslint/parser": "^8.61.1",
|
|
62
|
+
"@typescript-eslint/rule-tester": "^8.61.1",
|
|
63
|
+
"tsup": "^8.5.1",
|
|
64
|
+
"typescript": "^5.6.0",
|
|
65
|
+
"vitest": "^3"
|
|
66
|
+
}
|
|
67
|
+
}
|