Skip to content

Commit e849f10

Browse files
authored
Merge hotfix/propagate-child-events-to-parent into main (#228)
Hotfix release v1.0.2 fix(cli): prevent missing child exit events in parent process
2 parents f5cbd0a + 2e3c9de commit e849f10

File tree

7 files changed

+189
-12
lines changed

7 files changed

+189
-12
lines changed

CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
# Changelog
2+
3+
## [1.0.2](https://github.com/patoale/envloadr/compare/v1.0.1...v1.0.2) (August 7, 2025)
4+
5+
- Fixed main process exiting with code 0 regardless of child’s exit code. (@patoale in #228)

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "envloadr",
3-
"version": "1.0.1",
3+
"version": "1.0.2",
44
"description": "Simplifies the process of loading environment variables from files",
55
"bin": {
66
"envloadr": "bin/cli.js"

src/cli/index.ts

Lines changed: 17 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,11 @@ import {
55
DEFAULT_VERBOSE,
66
} from '@/config';
77
import { parseEnvFiles } from '@/envFile';
8+
import { buildHelp } from './help';
89
import { parse } from './parser';
910
import schema from './schema';
10-
import { buildHelp } from './help';
11-
import type { Options } from './parser/types';
11+
import { syncEvents } from './utils/childProcess';
12+
import type { Command, Options } from './parser/types';
1213

1314
function normalizeOptions(options: Options<typeof schema> | undefined) {
1415
const files = options?.files ?? [DEFAULT_ENV_FILE_PATH];
@@ -19,6 +20,15 @@ function normalizeOptions(options: Options<typeof schema> | undefined) {
1920
return { files, override, verbose };
2021
}
2122

23+
function executeCommand({ args, name }: Command, env: NodeJS.ProcessEnv) {
24+
const child = spawn(name, args, {
25+
stdio: 'inherit',
26+
env,
27+
});
28+
29+
syncEvents(process, child);
30+
}
31+
2232
export function run() {
2333
const args = process.argv.slice(2);
2434

@@ -29,10 +39,10 @@ export function run() {
2939
}
3040

3141
const { files, override, verbose } = normalizeOptions(options);
32-
const env = parseEnvFiles(files, { override, verbose });
42+
const customEnv = parseEnvFiles(files, { override, verbose });
3343

34-
spawn(command.name, command.args, {
35-
stdio: 'inherit',
36-
env: override ? { ...process.env, ...env } : { ...env, ...process.env },
37-
});
44+
const env = override
45+
? { ...process.env, ...customEnv }
46+
: { ...customEnv, ...process.env };
47+
executeCommand(command, env);
3848
}

src/cli/parser/types.ts

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -28,14 +28,16 @@ type OptionType<T extends OptionSpecParam | undefined> = T extends BooleanType
2828
? OptionSpecTypeMap[T['type']]
2929
: boolean;
3030

31+
export type Command = {
32+
name: string;
33+
args: string[];
34+
};
35+
3136
export type Options<T extends SpecSchema> = {
3237
[K in keyof T]?: OptionType<T[K]['param']>;
3338
};
3439

3540
export type Args<T extends SpecSchema> = {
36-
command: {
37-
name: string;
38-
args: string[];
39-
};
41+
command: Command;
4042
options?: Options<T>;
4143
};

src/cli/utils/childProcess.ts

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import { ChildProcess } from 'child_process';
2+
3+
const SIGNALS: NodeJS.Signals[] = ['SIGINT', 'SIGTERM', 'SIGHUP'];
4+
5+
type SignalListener = {
6+
signal: NodeJS.Signals;
7+
listener: () => boolean;
8+
};
9+
10+
function propagateExit(
11+
sourceProcess: ChildProcess,
12+
targetProcess: NodeJS.Process,
13+
cleanupListeners: SignalListener[],
14+
) {
15+
sourceProcess.on('exit', (code, signal) => {
16+
cleanupListeners.forEach(({ signal, listener }) =>
17+
targetProcess.removeListener(signal, listener),
18+
);
19+
20+
if (signal !== null) targetProcess.kill(targetProcess.pid, signal);
21+
else targetProcess.exit(code ?? 1);
22+
});
23+
24+
sourceProcess.on('error', (err) => {
25+
console.error(
26+
`Command "${sourceProcess.spawnargs.join(' ')}" failed to launch: ${err.message}`,
27+
);
28+
29+
targetProcess.exit(1);
30+
});
31+
}
32+
33+
function propagateSignals(
34+
sourceProcess: NodeJS.Process,
35+
targetProcess: ChildProcess,
36+
) {
37+
const listeners = SIGNALS.map((signal) => ({
38+
signal,
39+
listener: () => targetProcess.kill(signal),
40+
}));
41+
42+
listeners.forEach(({ listener, signal }) =>
43+
sourceProcess.on(signal, listener),
44+
);
45+
46+
return listeners;
47+
}
48+
49+
export function syncEvents(parent: NodeJS.Process, child: ChildProcess): void {
50+
const removeListeners = propagateSignals(parent, child);
51+
propagateExit(child, parent, removeListeners);
52+
}

test/cli/index.spec.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { run } from '@/cli';
88
import { buildHelp } from '@/cli/help';
99
import * as cliParser from '@/cli/parser';
1010
import schema from '@/cli/schema';
11+
import * as childProcessUtils from '@/cli/utils/childProcess';
1112
import * as envFile from '@/envFile';
1213
import type { Args, SpecSchema } from '@/cli/parser/types';
1314

@@ -18,6 +19,7 @@ describe('run', () => {
1819
>;
1920
let parseEnvFilesSpy: jest.SpyInstance;
2021
let spawnSpy: jest.SpyInstance;
22+
let syncEventsSpy: jest.SpyInstance;
2123

2224
beforeAll(() => {
2325
// Testing these functions is not the objective of this suite,
@@ -27,12 +29,16 @@ describe('run', () => {
2729
.spyOn(envFile, 'parseEnvFiles')
2830
.mockImplementation();
2931
spawnSpy = jest.spyOn(childProcess, 'spawn').mockImplementation();
32+
syncEventsSpy = jest
33+
.spyOn(childProcessUtils, 'syncEvents')
34+
.mockImplementation();
3035
});
3136

3237
afterAll(() => {
3338
cliParseSpy.mockRestore();
3439
parseEnvFilesSpy.mockRestore();
3540
spawnSpy.mockRestore();
41+
syncEventsSpy.mockRestore();
3642
});
3743

3844
it('should print help message when help option is enabled', () => {

test/cli/utils/childProcess.spec.ts

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
import { syncEvents } from '@/cli/utils/childProcess';
2+
3+
import { ChildProcess } from 'child_process';
4+
import { EventEmitter } from 'events';
5+
6+
describe('syncEvents', () => {
7+
const mockChildKill = jest.fn();
8+
const mockParentExit = jest.fn();
9+
const mockParentKill = jest.fn();
10+
const mockParentRemoveListener = jest.fn();
11+
12+
let child: ChildProcess;
13+
let parent: NodeJS.Process;
14+
15+
beforeEach(() => {
16+
parent = Object.assign(new EventEmitter(), {
17+
exit: mockParentExit,
18+
kill: mockParentKill,
19+
removeListener: mockParentRemoveListener,
20+
}) as unknown as NodeJS.Process;
21+
22+
child = Object.assign(new EventEmitter(), {
23+
kill: mockChildKill,
24+
spawnargs: ['node', 'example.js'],
25+
}) as unknown as ChildProcess;
26+
});
27+
28+
it("should exit parent process with child's exit code when child exits normally", () => {
29+
syncEvents(parent, child);
30+
31+
child.emit('exit', 0, null);
32+
33+
expect(mockParentExit).toHaveBeenCalledWith(0);
34+
});
35+
36+
it('should exit parent process with code 1 when child exits with null code', () => {
37+
syncEvents(parent, child);
38+
39+
child.emit('exit', null, null);
40+
41+
expect(mockParentExit).toHaveBeenCalledWith(1);
42+
});
43+
44+
it('should terminate parent process with same signal when child is killed by signal', () => {
45+
syncEvents(parent, child);
46+
47+
child.emit('exit', null, 'SIGTERM');
48+
49+
expect(mockParentKill).toHaveBeenCalledWith(parent.pid, 'SIGTERM');
50+
});
51+
52+
it('should log error when child fails to launch', () => {
53+
const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation();
54+
55+
try {
56+
syncEvents(parent, child);
57+
58+
child.emit('error', new Error('Launch failed'));
59+
60+
expect(consoleErrorSpy).toHaveBeenCalledWith(
61+
'Command "node example.js" failed to launch: Launch failed',
62+
);
63+
} finally {
64+
consoleErrorSpy.mockRestore();
65+
}
66+
});
67+
68+
it('should exit parent process with code 1 when child fails to launch', () => {
69+
syncEvents(parent, child);
70+
71+
child.emit('error', new Error('Launch failed'));
72+
73+
expect(mockParentExit).toHaveBeenCalledWith(1);
74+
});
75+
76+
it('should terminate child process with same signal when parent is killed by signal', () => {
77+
syncEvents(parent, child);
78+
79+
parent.emit('SIGTERM');
80+
81+
expect(mockChildKill).toHaveBeenCalledWith('SIGTERM');
82+
});
83+
84+
it("should remove child's signal listeners from parent process when child exits", () => {
85+
syncEvents(parent, child);
86+
87+
child.emit('exit', 0, null);
88+
89+
expect(mockParentRemoveListener).toHaveBeenCalledWith(
90+
'SIGINT',
91+
expect.any(Function),
92+
);
93+
expect(mockParentRemoveListener).toHaveBeenCalledWith(
94+
'SIGTERM',
95+
expect.any(Function),
96+
);
97+
expect(mockParentRemoveListener).toHaveBeenCalledWith(
98+
'SIGHUP',
99+
expect.any(Function),
100+
);
101+
});
102+
});

0 commit comments

Comments
 (0)