Repository: sindresorhus/execa (JavaScript)

Issue:
Cancel generators when the process exits
When passing a transform to the `stdin` option, the generator function starts running. 
If the process errors, the generator should stop the next time `yield` is called. 
Currently, this is not the case. The generator keeps running until completion.

This bug could make the child process hang forever, for example when using a transform that generates values infinitely (like the Unix command yes).


Discussion:

Patch:
diff --git a/lib/stdio/transform.js b/lib/stdio/transform.js
index 6f20171128..1d42a36630 100644
--- a/lib/stdio/transform.js
+++ b/lib/stdio/transform.js
@@ -4,12 +4,16 @@ import {callbackify} from 'node:util';
 // Transform an array of generator functions into a `Transform` stream.
 // `Duplex.from(generator)` cannot be used because it does not allow setting the `objectMode` and `highWaterMark`.
 export const generatorsToTransform = (generators, {transformAsync, finalAsync, writableObjectMode, readableObjectMode}) => {
+	const state = {};
 	const transformMethod = transformAsync
-		? pushChunks.bind(undefined, transformChunk)
+		? pushChunks.bind(undefined, transformChunk, state)
 		: pushChunksSync.bind(undefined, transformChunkSync);
 	const finalMethod = transformAsync || finalAsync
-		? pushChunks.bind(undefined, finalChunks)
+		? pushChunks.bind(undefined, finalChunks, state)
 		: pushChunksSync.bind(undefined, finalChunksSync);
+	const destroyMethod = transformAsync || finalAsync
+		? destroyTransform.bind(undefined, state)
+		: undefined;
 
 	return new Transform({
 		writableObjectMode,
@@ -22,12 +26,19 @@ export const generatorsToTransform = (generators, {transformAsync, finalAsync, w
 		flush(done) {
 			finalMethod([generators], this, done);
 		},
+		destroy: destroyMethod,
 	});
 };
 
-const pushChunks = callbackify(async (getChunks, args, transformStream) => {
-	for await (const chunk of getChunks(...args)) {
-		transformStream.push(chunk);
+const pushChunks = callbackify(async (getChunks, state, args, transformStream) => {
+	state.currentIterable = getChunks(...args);
+
+	try {
+		for await (const chunk of state.currentIterable) {
+			transformStream.push(chunk);
+		}
+	} finally {
+		delete state.currentIterable;
 	}
 });
 
@@ -61,6 +72,18 @@ const generatorFinalChunks = async function * (final, index, generators) {
 	}
 };
 
+// Cancel any ongoing async generator when the Transform is destroyed, e.g. when the process errors
+const destroyTransform = callbackify(async ({currentIterable}, error) => {
+	if (currentIterable !== undefined) {
+		await (error ? currentIterable.throw(error) : currentIterable.return());
+		return;
+	}
+
+	if (error) {
+		throw error;
+	}
+});
+
 // Duplicate the code above but as synchronous functions.
 // This is a performance optimization when the `transform`/`flush` function is synchronous, which is the common case.
 const pushChunksSync = (getChunksSync, args, transformStream, done) => {


Test patch:
diff --git a/test/stdio/generator.js b/test/stdio/generator.js
index a7e1a19ee1..a7a9af9d84 100644
--- a/test/stdio/generator.js
+++ b/test/stdio/generator.js
@@ -1,4 +1,5 @@
 import {Buffer} from 'node:buffer';
+import {once} from 'node:events';
 import {readFile, writeFile, rm} from 'node:fs/promises';
 import {getDefaultHighWaterMark, PassThrough} from 'node:stream';
 import {setTimeout, scheduler} from 'node:timers/promises';
@@ -704,6 +705,26 @@ test('Generators errors make process fail even when other input generators do no
 	await t.throwsAsync(childProcess, {message: GENERATOR_ERROR_REGEXP});
 });
 
-test('Generators are canceled on early process exit', async t => {
+const testGeneratorCancel = async (t, error) => {
+	const childProcess = execa('noop.js', {stdout: infiniteGenerator});
+	await once(childProcess.stdout, 'data');
+	childProcess.stdout.destroy(error);
+	await t.throwsAsync(childProcess);
+};
+
+test('Running generators are canceled on process abort', testGeneratorCancel, undefined);
+test('Running generators are canceled on process error', testGeneratorCancel, new Error('test'));
+
+const testGeneratorDestroy = async (t, transform) => {
+	const childProcess = execa('forever.js', {stdout: transform});
+	const error = new Error('test');
+	childProcess.stdout.destroy(error);
+	t.is(await t.throwsAsync(childProcess), error);
+};
+
+test('Generators are destroyed on process error, sync', testGeneratorDestroy, noopGenerator(false));
+test('Generators are destroyed on process error, async', testGeneratorDestroy, infiniteGenerator);
+
+test('Generators are destroyed on early process exit', async t => {
 	await t.throwsAsync(execa('noop.js', {stdout: infiniteGenerator, uid: -1}));
 });
diff --git a/test/stdio/iterable.js b/test/stdio/iterable.js
index fd555ebde0..c097a7d7ea 100644
--- a/test/stdio/iterable.js
+++ b/test/stdio/iterable.js
@@ -121,3 +121,10 @@ const testMultipleIterable = async (t, index) => {
 
 test('stdin option can be multiple iterables', testMultipleIterable, 0);
 test('stdio[*] option can be multiple iterables', testMultipleIterable, 3);
+
+test('stdin option iterable is canceled on process error', async t => {
+	const iterable = infiniteGenerator();
+	await t.throwsAsync(execa('stdin.js', {stdin: iterable, timeout: 1}), {message: /timed out/});
+	// eslint-disable-next-line no-unused-vars, no-empty
+	for await (const _ of iterable) {}
+});