Skip to content

Commit f609518

Browse files
committed
Fix ESM node processes being unable to fork into other scripts
Currently, Node processes instantiated through the `--esm` flag result in a child process being created so that the ESM loader can be registered. This works fine and is reasonable. The child process approach to register ESM hooks currently prevents the NodeJS `fork` method from being used because the `execArgv` propagated into forked processes causes `ts-node` (which is also propagated as child exec script -- this is good because it allows nested type resolution to work) to always execute the original entry-point, causing potential infinite loops because the designated fork module script is not executed as expected. This commit fixes this by not encoding the entry-point information into the state that is captured as part of the `execArgv`. Instead the entry-point information is always retrieved from the parsed rest command line arguments in the final stage (`phase4`). Additionally, this PR streamlines the boostrap mechanism to always call into the child script, resulting in reduced complexity, and also improved caching for user-initiated forked processes. i.e. the tsconfig resolution is not repeated multiple-times because forked processes are expected to preserve the existing ts-node project. More details can be found here TypeStrong#1831. Fixes TypeStrong#1812.
1 parent 0e0da59 commit f609518

File tree

33 files changed

+428
-217
lines changed

33 files changed

+428
-217
lines changed

src/bin.ts

Lines changed: 151 additions & 80 deletions
Large diffs are not rendered by default.

src/child/child-entrypoint.ts

Lines changed: 3 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,11 @@
1-
import { BootstrapState, bootstrap } from '../bin';
1+
import { completeBootstrap, BootstrapStateForChild } from '../bin';
22
import { argPrefix, compress, decompress } from './argv-payload';
33

44
const base64ConfigArg = process.argv[2];
55
if (!base64ConfigArg.startsWith(argPrefix)) throw new Error('unexpected argv');
66
const base64Payload = base64ConfigArg.slice(argPrefix.length);
7-
const state = decompress(base64Payload) as BootstrapState;
7+
const state = decompress(base64Payload) as BootstrapStateForChild;
88

9-
state.isInChildProcess = true;
10-
state.tsNodeScript = __filename;
11-
state.parseArgvResult.argv = process.argv;
129
state.parseArgvResult.restArgs = process.argv.slice(3);
1310

14-
// Modify and re-compress the payload delivered to subsequent child processes.
15-
// This logic may be refactored into bin.ts by https://github.com/TypeStrong/ts-node/issues/1831
16-
if (state.isCli) {
17-
const stateForChildren: BootstrapState = {
18-
...state,
19-
isCli: false,
20-
};
21-
state.parseArgvResult.argv[2] = `${argPrefix}${compress(stateForChildren)}`;
22-
}
23-
24-
bootstrap(state);
11+
completeBootstrap(state);

src/child/child-exec-args.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import { pathToFileURL } from 'url';
2+
import { brotliCompressSync } from 'zlib';
3+
import type { BootstrapStateForChild } from '../bin';
4+
import { versionGteLt } from '../util';
5+
6+
const argPrefix = '--brotli-base64-config=';
7+
8+
export function getChildProcessArguments(
9+
enableEsmLoader: boolean,
10+
state: BootstrapStateForChild
11+
) {
12+
if (enableEsmLoader && !versionGteLt(process.versions.node, '12.17.0')) {
13+
throw new Error(
14+
'`ts-node-esm` and `ts-node --esm` require node version 12.17.0 or newer.'
15+
);
16+
}
17+
18+
const nodeExecArgs = [];
19+
20+
if (enableEsmLoader) {
21+
nodeExecArgs.push(
22+
'--require',
23+
require.resolve('./child-require.js'),
24+
'--loader',
25+
// Node on Windows doesn't like `c:\` absolute paths here; must be `file:///c:/`
26+
pathToFileURL(require.resolve('../../child-loader.mjs')).toString()
27+
);
28+
}
29+
30+
const childScriptArgs = [
31+
`${argPrefix}${brotliCompressSync(
32+
Buffer.from(JSON.stringify(state), 'utf8')
33+
).toString('base64')}`,
34+
];
35+
36+
return {
37+
nodeExecArgs,
38+
childScriptArgs,
39+
childScriptPath: require.resolve('./child-entrypoint.js'),
40+
};
41+
}

src/child/spawn-child-with-esm.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import { fork } from 'child_process';
2+
import type { BootstrapStateForChild } from '../bin';
3+
import { getChildProcessArguments } from './child-exec-args';
4+
5+
/**
6+
* @internal
7+
* @param state Bootstrap state to be transferred into the child process.
8+
* @param enableEsmLoader Whether to enable the ESM loader or not. This option may
9+
* be removed in the future when `--esm` is no longer a choice.
10+
* @param targetCwd Working directory to be preserved when transitioning to
11+
* the child process.
12+
*/
13+
export function callInChildWithEsm(
14+
state: BootstrapStateForChild,
15+
targetCwd: string
16+
) {
17+
const { childScriptArgs, childScriptPath, nodeExecArgs } =
18+
getChildProcessArguments(/* enableEsmLoader */ true, state);
19+
20+
childScriptArgs.push(...state.parseArgvResult.restArgs);
21+
22+
const child = fork(childScriptPath, childScriptArgs, {
23+
stdio: 'inherit',
24+
execArgv: [...process.execArgv, ...nodeExecArgs],
25+
cwd: targetCwd,
26+
});
27+
child.on('error', (error) => {
28+
console.error(error);
29+
process.exit(1);
30+
});
31+
child.on('exit', (code) => {
32+
child.removeAllListeners();
33+
process.off('SIGINT', sendSignalToChild);
34+
process.off('SIGTERM', sendSignalToChild);
35+
process.exitCode = code === null ? 1 : code;
36+
});
37+
// Ignore sigint and sigterm in parent; pass them to child
38+
process.on('SIGINT', sendSignalToChild);
39+
process.on('SIGTERM', sendSignalToChild);
40+
function sendSignalToChild(signal: string) {
41+
process.kill(child.pid, signal);
42+
}
43+
}

src/child/spawn-child.ts

Lines changed: 0 additions & 52 deletions
This file was deleted.

src/test/esm-loader.spec.ts

Lines changed: 37 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -362,10 +362,8 @@ test.suite('esm', (test) => {
362362
test.suite('esm child process working directory', (test) => {
363363
test('should have the correct working directory in the user entry-point', async () => {
364364
const { err, stdout, stderr } = await exec(
365-
`${BIN_PATH} --esm --cwd ./esm/ index.ts`,
366-
{
367-
cwd: resolve(TEST_DIR, 'working-dir'),
368-
}
365+
`${BIN_PATH} --esm index.ts`,
366+
{ cwd: './working-dir/esm/' }
369367
);
370368

371369
expect(err).toBe(null);
@@ -377,33 +375,57 @@ test.suite('esm', (test) => {
377375
test.suite('esm child process and forking', (test) => {
378376
test('should be able to fork vanilla NodeJS script', async () => {
379377
const { err, stdout, stderr } = await exec(
380-
`${BIN_PATH} --esm --cwd ./esm-child-process/ ./process-forking-js/index.ts`
378+
`${BIN_PATH} --esm index.ts`,
379+
{ cwd: './esm-child-process/process-forking-js-worker/' }
381380
);
382381

383382
expect(err).toBe(null);
384383
expect(stdout.trim()).toBe('Passing: from main');
385384
expect(stderr).toBe('');
386385
});
387386

388-
test('should be able to fork TypeScript script', async () => {
387+
test('should be able to fork into a nested TypeScript ESM script', async () => {
389388
const { err, stdout, stderr } = await exec(
390-
`${BIN_PATH} --esm --cwd ./esm-child-process/ ./process-forking-ts/index.ts`
389+
`${BIN_PATH} --esm index.ts`,
390+
{ cwd: './esm-child-process/process-forking-nested-esm/' }
391391
);
392392

393393
expect(err).toBe(null);
394394
expect(stdout.trim()).toBe('Passing: from main');
395395
expect(stderr).toBe('');
396396
});
397397

398-
test('should be able to fork TypeScript script by absolute path', async () => {
399-
const { err, stdout, stderr } = await exec(
400-
`${BIN_PATH} --esm --cwd ./esm-child-process/ ./process-forking-ts-abs/index.ts`
401-
);
398+
test(
399+
'should be possible to fork into a nested TypeScript script with respect to ' +
400+
'the working directory',
401+
async () => {
402+
const { err, stdout, stderr } = await exec(
403+
`${BIN_PATH} --esm index.ts`,
404+
{ cwd: './esm-child-process/process-forking-nested-relative/' }
405+
);
402406

403-
expect(err).toBe(null);
404-
expect(stdout.trim()).toBe('Passing: from main');
405-
expect(stderr).toBe('');
406-
});
407+
expect(err).toBe(null);
408+
expect(stdout.trim()).toBe('Passing: from main');
409+
expect(stderr).toBe('');
410+
}
411+
);
412+
413+
test.suite(
414+
'with NodeNext TypeScript resolution and `.mts` extension',
415+
(test) => {
416+
test.runIf(tsSupportsStableNodeNextNode16);
417+
418+
test('should be able to fork into a nested TypeScript ESM script', async () => {
419+
const { err, stdout, stderr } = await exec(
420+
`${BIN_PATH} --esm ./esm-child-process/process-forking-nested-esm-node-next/index.mts`
421+
);
422+
423+
expect(err).toBe(null);
424+
expect(stdout.trim()).toBe('Passing: from main');
425+
expect(stderr).toBe('');
426+
});
427+
}
428+
);
407429
});
408430

409431
test.suite('parent passes signals to child', (test) => {

src/test/helpers.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,10 @@ export const BIN_SCRIPT_PATH = join(
3333
TEST_DIR,
3434
'node_modules/.bin/ts-node-script'
3535
);
36+
export const CHILD_ENTRY_POINT_SCRIPT = join(
37+
TEST_DIR,
38+
'node_modules/ts-node/dist/child/child-entrypoint.js'
39+
);
3640
export const BIN_CWD_PATH = join(TEST_DIR, 'node_modules/.bin/ts-node-cwd');
3741
export const BIN_ESM_PATH = join(TEST_DIR, 'node_modules/.bin/ts-node-esm');
3842

src/test/index.spec.ts

Lines changed: 39 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { tmpdir } from 'os';
55
import semver = require('semver');
66
import {
77
BIN_PATH_JS,
8+
CHILD_ENTRY_POINT_SCRIPT,
89
CMD_TS_NODE_WITH_PROJECT_TRANSPILE_ONLY_FLAG,
910
nodeSupportsEsmHooks,
1011
nodeSupportsSpawningChildProcess,
@@ -619,30 +620,27 @@ test.suite('ts-node', (test) => {
619620

620621
test('should have the correct working directory in the user entry-point', async () => {
621622
const { err, stdout, stderr } = await exec(
622-
`${BIN_PATH} --cwd ./cjs index.ts`,
623-
{
624-
cwd: resolve(TEST_DIR, 'working-dir'),
625-
}
623+
`${BIN_PATH} --cwd ./working-dir/cjs/ index.ts`
626624
);
627625

628626
expect(err).toBe(null);
629627
expect(stdout.trim()).toBe('Passing');
630628
expect(stderr).toBe('');
631629
});
632630

633-
// Disabled due to bug:
634-
// --cwd is passed to forked children when not using --esm, erroneously affects their entrypoint resolution.
635-
// tracked/fixed by either https://github.com/TypeStrong/ts-node/issues/1834
636-
// or https://github.com/TypeStrong/ts-node/issues/1831
637-
test.skip('should be able to fork into a nested TypeScript script with a modified working directory', async () => {
638-
const { err, stdout, stderr } = await exec(
639-
`${BIN_PATH} --cwd ./working-dir/forking/ index.ts`
640-
);
631+
test(
632+
'should be able to fork into a nested TypeScript script with a modified ' +
633+
'working directory',
634+
async () => {
635+
const { err, stdout, stderr } = await exec(
636+
`${BIN_PATH} --cwd ./working-dir/forking/ index.ts`
637+
);
641638

642-
expect(err).toBe(null);
643-
expect(stdout.trim()).toBe('Passing: from main');
644-
expect(stderr).toBe('');
645-
});
639+
expect(err).toBe(null);
640+
expect(stdout.trim()).toBe('Passing: from main');
641+
expect(stderr).toBe('');
642+
}
643+
);
646644

647645
test.suite('should read ts-node options from tsconfig.json', (test) => {
648646
const BIN_EXEC = `"${BIN_PATH}" --project tsconfig-options/tsconfig.json`;
@@ -1132,7 +1130,23 @@ test('Falls back to transpileOnly when ts compiler returns emitSkipped', async (
11321130

11331131
test.suite('node environment', (test) => {
11341132
test.suite('Sets argv and execArgv correctly in forked processes', (test) => {
1135-
forkTest(`node --no-warnings ${BIN_PATH_JS}`, BIN_PATH_JS, '--no-warnings');
1133+
forkTest(`node --no-warnings ${BIN_PATH_JS}`, BIN_PATH_JS, [
1134+
'--no-warnings',
1135+
]);
1136+
1137+
forkTest(
1138+
`node --no-warnings ${BIN_PATH_JS} --esm`,
1139+
CHILD_ENTRY_POINT_SCRIPT,
1140+
[
1141+
'--no-warnings',
1142+
// Forked child processes should directly receive the necessary ESM loader
1143+
// Node flags through `execArgv`, avoiding doubled child process spawns.
1144+
'--require',
1145+
expect.stringMatching(/child-require.js$/),
1146+
'--loader',
1147+
expect.stringMatching(/child-loader.mjs$/),
1148+
]
1149+
);
11361150
forkTest(
11371151
`${BIN_PATH}`,
11381152
process.platform === 'win32' ? BIN_PATH_JS : BIN_PATH
@@ -1141,7 +1155,7 @@ test.suite('node environment', (test) => {
11411155
function forkTest(
11421156
command: string,
11431157
expectParentArgv0: string,
1144-
nodeFlag?: string
1158+
nodeFlags?: (string | ReturnType<typeof expect.stringMatching>)[]
11451159
) {
11461160
test(command, async (t) => {
11471161
const { err, stderr, stdout } = await exec(
@@ -1151,16 +1165,20 @@ test.suite('node environment', (test) => {
11511165
expect(stderr).toBe('');
11521166
const generations = stdout.split('\n');
11531167
const expectation = {
1154-
execArgv: [nodeFlag, BIN_PATH_JS, '--skipIgnore'].filter((v) => v),
1168+
execArgv: [
1169+
...(nodeFlags || []),
1170+
CHILD_ENTRY_POINT_SCRIPT,
1171+
expect.stringMatching(/^--brotli-base64-config=.*/),
1172+
],
11551173
argv: [
1156-
// Note: argv[0] is *always* BIN_PATH_JS in child & grandchild
1174+
// Note: argv[0] is *always* CHILD_ENTRY_POINT_SCRIPT in child & grandchild
11571175
expectParentArgv0,
11581176
resolve(TEST_DIR, 'recursive-fork/index.ts'),
11591177
'argv2',
11601178
],
11611179
};
11621180
expect(JSON.parse(generations[0])).toMatchObject(expectation);
1163-
expectation.argv[0] = BIN_PATH_JS;
1181+
expectation.argv[0] = CHILD_ENTRY_POINT_SCRIPT;
11641182
expect(JSON.parse(generations[1])).toMatchObject(expectation);
11651183
expect(JSON.parse(generations[2])).toMatchObject(expectation);
11661184
});

tests/esm-child-process/process-forking-js/index.ts renamed to tests/esm-child-process/process-forking-js-worker/index.ts

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,9 @@
11
import { fork } from 'child_process';
2-
import { dirname } from 'path';
3-
import { fileURLToPath } from 'url';
42

53
// Initially set the exit code to non-zero. We only set it to `0` when the
64
// worker process finishes properly with the expected stdout message.
75
process.exitCode = 1;
86

9-
process.chdir(dirname(fileURLToPath(import.meta.url)));
10-
117
const workerProcess = fork('./worker.js', [], {
128
stdio: 'pipe',
139
});
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,5 @@
11
{
22
"compilerOptions": {
33
"module": "ESNext"
4-
},
5-
"ts-node": {
6-
"swc": true
74
}
85
}

0 commit comments

Comments
 (0)