Skip to content

Commit 34fe319

Browse files
authoredMar 16, 2022
fix: kill other running tasks on failure (#1117)
1 parent 517235d commit 34fe319

9 files changed

+149
-16
lines changed
 

‎lib/resolveTaskFn.js

+38-3
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,17 @@ import { redBright, dim } from 'colorette'
22
import execa from 'execa'
33
import debug from 'debug'
44
import { parseArgsStringToArgv } from 'string-argv'
5+
import pidTree from 'pidtree'
56

67
import { error, info } from './figures.js'
78
import { getInitialState } from './state.js'
89
import { TaskError } from './symbols.js'
910

11+
const ERROR_CHECK_INTERVAL = 200
12+
1013
const debugLog = debug('lint-staged:resolveTaskFn')
1114

12-
const getTag = ({ code, killed, signal }) => signal || (killed && 'KILLED') || code || 'FAILED'
15+
const getTag = ({ code, killed, signal }) => (killed && 'KILLED') || signal || code || 'FAILED'
1316

1417
/**
1518
* Handle task console output.
@@ -43,6 +46,34 @@ const handleOutput = (command, result, ctx, isError = false) => {
4346
}
4447
}
4548

49+
/**
50+
* Interrupts the execution of the execa process that we spawned if
51+
* another task adds an error to the context.
52+
*
53+
* @param {Object} ctx
54+
* @param {execa.ExecaChildProcess<string>} execaChildProcess
55+
* @returns {function(): void} Function that clears the interval that
56+
* checks the context.
57+
*/
58+
const interruptExecutionOnError = (ctx, execaChildProcess) => {
59+
async function loop() {
60+
if (ctx.errors.size > 0) {
61+
const ids = await pidTree(execaChildProcess.pid)
62+
ids.forEach(process.kill)
63+
64+
// The execa process is killed separately in order
65+
// to get the `KILLED` status.
66+
execaChildProcess.kill()
67+
}
68+
}
69+
70+
const loopIntervalId = setInterval(loop, ERROR_CHECK_INTERVAL)
71+
72+
return () => {
73+
clearInterval(loopIntervalId)
74+
}
75+
}
76+
4677
/**
4778
* Create a error output dependding on process result.
4879
*
@@ -101,9 +132,13 @@ export const resolveTaskFn = ({
101132
debugLog('execaOptions:', execaOptions)
102133

103134
return async (ctx = getInitialState()) => {
104-
const result = await (shell
135+
const execaChildProcess = shell
105136
? execa.command(isFn ? command : `${command} ${files.join(' ')}`, execaOptions)
106-
: execa(cmd, isFn ? args : args.concat(files), execaOptions))
137+
: execa(cmd, isFn ? args : args.concat(files), execaOptions)
138+
139+
const quitInterruptCheck = interruptExecutionOnError(ctx, execaChildProcess)
140+
const result = await execaChildProcess
141+
quitInterruptCheck()
107142

108143
if (result.failed || result.killed || result.signal != null) {
109144
throw makeErr(command, result, ctx)

‎package-lock.json

+17
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎package.json

+1
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@
4242
"micromatch": "^4.0.4",
4343
"normalize-path": "^3.0.0",
4444
"object-inspect": "^1.12.0",
45+
"pidtree": "^0.5.0",
4546
"string-argv": "^0.3.1",
4647
"supports-color": "^9.2.1",
4748
"yaml": "^1.10.2"

‎test/__mocks__/execa.js

+3-1
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
1+
import { createExecaReturnValue } from '../utils/createExecaReturnValue'
2+
13
const execa = jest.fn(() =>
2-
Promise.resolve({
4+
createExecaReturnValue({
35
stdout: 'a-ok',
46
stderr: '',
57
code: 0,

‎test/__mocks__/pidtree.js

+3
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
module.exports = () => {
2+
return Promise.resolve([])
3+
}

‎test/resolveTaskFn.spec.js

+39-8
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,14 @@ import { resolveTaskFn } from '../lib/resolveTaskFn'
44
import { getInitialState } from '../lib/state'
55
import { TaskError } from '../lib/symbols'
66

7+
import { createExecaReturnValue } from './utils/createExecaReturnValue'
8+
79
const defaultOpts = { files: ['test.js'] }
810

11+
function mockExecaImplementationOnce(value) {
12+
execa.mockImplementationOnce(() => createExecaReturnValue(value))
13+
}
14+
915
describe('resolveTaskFn', () => {
1016
beforeEach(() => {
1117
execa.mockClear()
@@ -135,7 +141,7 @@ describe('resolveTaskFn', () => {
135141

136142
it('should throw error for failed linters', async () => {
137143
expect.assertions(1)
138-
execa.mockResolvedValueOnce({
144+
mockExecaImplementationOnce({
139145
stdout: 'Mock error',
140146
stderr: '',
141147
code: 0,
@@ -149,7 +155,7 @@ describe('resolveTaskFn', () => {
149155

150156
it('should throw error for interrupted processes', async () => {
151157
expect.assertions(1)
152-
execa.mockResolvedValueOnce({
158+
mockExecaImplementationOnce({
153159
stdout: 'Mock error',
154160
stderr: '',
155161
code: 0,
@@ -167,7 +173,7 @@ describe('resolveTaskFn', () => {
167173

168174
it('should throw error for killed processes without signal', async () => {
169175
expect.assertions(1)
170-
execa.mockResolvedValueOnce({
176+
mockExecaImplementationOnce({
171177
stdout: 'Mock error',
172178
stderr: '',
173179
code: 0,
@@ -192,7 +198,7 @@ describe('resolveTaskFn', () => {
192198
})
193199

194200
it('should add TaskError on error', async () => {
195-
execa.mockResolvedValueOnce({
201+
mockExecaImplementationOnce({
196202
stdout: 'Mock error',
197203
stderr: '',
198204
code: 0,
@@ -210,7 +216,7 @@ describe('resolveTaskFn', () => {
210216

211217
it('should not add output when there is none', async () => {
212218
expect.assertions(2)
213-
execa.mockResolvedValueOnce({
219+
mockExecaImplementationOnce({
214220
stdout: '',
215221
stderr: '',
216222
code: 0,
@@ -236,7 +242,7 @@ describe('resolveTaskFn', () => {
236242

237243
it('should add output even when task succeeds if `verbose: true`', async () => {
238244
expect.assertions(2)
239-
execa.mockResolvedValueOnce({
245+
mockExecaImplementationOnce({
240246
stdout: 'Mock success',
241247
stderr: '',
242248
code: 0,
@@ -266,7 +272,7 @@ describe('resolveTaskFn', () => {
266272

267273
it('should not add title to output when task errors while quiet', async () => {
268274
expect.assertions(2)
269-
execa.mockResolvedValueOnce({
275+
mockExecaImplementationOnce({
270276
stdout: '',
271277
stderr: 'stderr',
272278
code: 1,
@@ -296,7 +302,7 @@ describe('resolveTaskFn', () => {
296302

297303
it('should not print anything when task errors without output while quiet', async () => {
298304
expect.assertions(2)
299-
execa.mockResolvedValueOnce({
305+
mockExecaImplementationOnce({
300306
stdout: '',
301307
stderr: '',
302308
code: 1,
@@ -321,4 +327,29 @@ describe('resolveTaskFn', () => {
321327
}
322328
`)
323329
})
330+
331+
it('should kill a long running task when an error is added to the context', async () => {
332+
execa.mockImplementationOnce(() =>
333+
createExecaReturnValue(
334+
{
335+
stdout: 'a-ok',
336+
stderr: '',
337+
code: 0,
338+
cmd: 'mock cmd',
339+
failed: false,
340+
killed: false,
341+
signal: null,
342+
},
343+
1000
344+
)
345+
)
346+
347+
const context = getInitialState()
348+
const taskFn = resolveTaskFn({ command: 'node' })
349+
const taskPromise = taskFn(context)
350+
351+
context.errors.add({})
352+
353+
await expect(taskPromise).rejects.toThrowErrorMatchingInlineSnapshot(`"node [KILLED]"`)
354+
})
324355
})

‎test/resolveTaskFn.unmocked.spec.js

+22
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { resolveTaskFn } from '../lib/resolveTaskFn'
2+
import { getInitialState } from '../lib/state'
23

34
jest.unmock('execa')
45

@@ -13,4 +14,25 @@ describe('resolveTaskFn', () => {
1314

1415
await expect(taskFn()).resolves.toMatchInlineSnapshot(`undefined`)
1516
})
17+
18+
it('should kill a long running task when another fails', async () => {
19+
const context = getInitialState()
20+
21+
const taskFn = resolveTaskFn({
22+
command: 'node',
23+
isFn: true,
24+
})
25+
const taskPromise = taskFn(context)
26+
27+
const taskFn2 = resolveTaskFn({
28+
command: 'node -e "process.exit(1)"',
29+
isFn: true,
30+
})
31+
const task2Promise = taskFn2(context)
32+
33+
await expect(task2Promise).rejects.toThrowErrorMatchingInlineSnapshot(
34+
`"node -e \\"process.exit(1)\\" [FAILED]"`
35+
)
36+
await expect(taskPromise).rejects.toThrowErrorMatchingInlineSnapshot(`"node [KILLED]"`)
37+
})
1638
})

‎test/runAll.spec.js

+6-4
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ import { ConfigNotFoundError, GitError } from '../lib/symbols'
1212
import * as searchConfigsNS from '../lib/searchConfigs'
1313
import * as getConfigGroupsNS from '../lib/getConfigGroups'
1414

15+
import { createExecaReturnValue } from './utils/createExecaReturnValue'
16+
1517
jest.mock('../lib/file')
1618
jest.mock('../lib/getStagedFiles')
1719
jest.mock('../lib/gitWorkflow')
@@ -192,7 +194,7 @@ describe('runAll', () => {
192194
expect.assertions(2)
193195
getStagedFiles.mockImplementationOnce(async () => ['sample.js'])
194196
execa.mockImplementation(() =>
195-
Promise.resolve({
197+
createExecaReturnValue({
196198
stdout: '',
197199
stderr: 'Linter finished with error',
198200
code: 1,
@@ -229,7 +231,7 @@ describe('runAll', () => {
229231
expect.assertions(2)
230232
getStagedFiles.mockImplementationOnce(async () => ['sample.js'])
231233
execa.mockImplementation(() =>
232-
Promise.resolve({
234+
createExecaReturnValue({
233235
stdout: '',
234236
stderr: '',
235237
code: 0,
@@ -252,8 +254,8 @@ describe('runAll', () => {
252254
LOG [STARTED] — 1 file
253255
LOG [STARTED] *.js — 1 file
254256
LOG [STARTED] echo \\"sample\\"
255-
ERROR [FAILED] echo \\"sample\\" [SIGINT]
256-
ERROR [FAILED] echo \\"sample\\" [SIGINT]
257+
ERROR [FAILED] echo \\"sample\\" [KILLED]
258+
ERROR [FAILED] echo \\"sample\\" [KILLED]
257259
LOG [SUCCESS] Running tasks for staged files...
258260
LOG [STARTED] Applying modifications from tasks...
259261
INFO [SKIPPED] Skipped because of errors from tasks.

‎test/utils/createExecaReturnValue.js

+20
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
export function createExecaReturnValue(value, executionTime) {
2+
const returnValue = { ...value }
3+
let triggerResolve
4+
let resolveTimeout
5+
6+
const returnedPromise = executionTime
7+
? new Promise((resolve) => {
8+
triggerResolve = resolve.bind(null, returnValue)
9+
resolveTimeout = setTimeout(triggerResolve, executionTime)
10+
})
11+
: Promise.resolve(returnValue)
12+
13+
returnedPromise.kill = () => {
14+
returnValue.killed = true
15+
clearTimeout(resolveTimeout)
16+
triggerResolve()
17+
}
18+
19+
return returnedPromise
20+
}

0 commit comments

Comments
 (0)
Please sign in to comment.