Skip to content

Commit 32d07e2

Browse files
Fix ESM node processes being unable to fork into other scripts (#1814)
* 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`). Fixes #1812. * Fix `--cwd` to actually set the working directory and work with ESM child process Currently the `--esm` option does not necessarily do what the documentation suggests. i.e. the script does not run as if the working directory is the specified directory. This commit fixes this, so that the option is useful for TSConfig resolution, as well as for controlling the script working directory. Also fixes that the CWD encoded in the bootstrap brotli state for the ESM child process messes with the entry-point resolution, if e.g. the entry-point in `child_process.fork` is relative to a specified `cwd`. * changes based on review * lint-fix * enable transpileOnly in new tests for performance * Tweak basic working dir tests to verify that --cwd affects entrypoint resolution but not process.cwd() * update forking tests: disable non --esm test with comment about known bug and link to tickets make tests set cwd for fork() call, to be sure it is respected and not overridden by --cwd * use swc compiler to avoid issue with ancient TS versions not understanding import.meta.url syntax * Remove tests that I think are redundant (but I've asked for confirmation in code review) * fix another issue with old TS * final review updates Co-authored-by: Andrew Bradley <[email protected]>
1 parent 86b63bf commit 32d07e2

File tree

25 files changed

+390
-51
lines changed

25 files changed

+390
-51
lines changed

src/bin.ts

+97-34
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,8 @@ export function main(
4848
const state: BootstrapState = {
4949
shouldUseChildProcess: false,
5050
isInChildProcess: false,
51-
entrypoint: __filename,
51+
isCli: true,
52+
tsNodeScript: __filename,
5253
parseArgvResult: args,
5354
};
5455
return bootstrap(state);
@@ -62,7 +63,12 @@ export function main(
6263
export interface BootstrapState {
6364
isInChildProcess: boolean;
6465
shouldUseChildProcess: boolean;
65-
entrypoint: string;
66+
/**
67+
* True if bootstrapping the ts-node CLI process or the direct child necessitated by `--esm`.
68+
* false if bootstrapping a subsequently `fork()`ed child.
69+
*/
70+
isCli: boolean;
71+
tsNodeScript: string;
6672
parseArgvResult: ReturnType<typeof parseArgv>;
6773
phase2Result?: ReturnType<typeof phase2>;
6874
phase3Result?: ReturnType<typeof phase3>;
@@ -73,12 +79,16 @@ export function bootstrap(state: BootstrapState) {
7379
if (!state.phase2Result) {
7480
state.phase2Result = phase2(state);
7581
if (state.shouldUseChildProcess && !state.isInChildProcess) {
82+
// Note: When transitioning into the child-process after `phase2`,
83+
// the updated working directory needs to be preserved.
7684
return callInChild(state);
7785
}
7886
}
7987
if (!state.phase3Result) {
8088
state.phase3Result = phase3(state);
8189
if (state.shouldUseChildProcess && !state.isInChildProcess) {
90+
// Note: When transitioning into the child-process after `phase2`,
91+
// the updated working directory needs to be preserved.
8292
return callInChild(state);
8393
}
8494
}
@@ -264,8 +274,7 @@ function parseArgv(argv: string[], entrypointArgs: Record<string, any>) {
264274
}
265275

266276
function phase2(payload: BootstrapState) {
267-
const { help, version, code, interactive, cwdArg, restArgs, esm } =
268-
payload.parseArgvResult;
277+
const { help, version, cwdArg, esm } = payload.parseArgvResult;
269278

270279
if (help) {
271280
console.log(`
@@ -319,28 +328,14 @@ Options:
319328
process.exit(0);
320329
}
321330

322-
// Figure out which we are executing: piped stdin, --eval, REPL, and/or entrypoint
323-
// This is complicated because node's behavior is complicated
324-
// `node -e code -i ./script.js` ignores -e
325-
const executeEval = code != null && !(interactive && restArgs.length);
326-
const executeEntrypoint = !executeEval && restArgs.length > 0;
327-
const executeRepl =
328-
!executeEntrypoint &&
329-
(interactive || (process.stdin.isTTY && !executeEval));
330-
const executeStdin = !executeEval && !executeRepl && !executeEntrypoint;
331-
332-
const cwd = cwdArg || process.cwd();
333-
/** Unresolved. May point to a symlink, not realpath. May be missing file extension */
334-
const scriptPath = executeEntrypoint ? resolve(cwd, restArgs[0]) : undefined;
331+
const cwd = cwdArg ? resolve(cwdArg) : process.cwd();
335332

333+
// If ESM is explicitly enabled through the flag, stage3 should be run in a child process
334+
// with the ESM loaders configured.
336335
if (esm) payload.shouldUseChildProcess = true;
336+
337337
return {
338-
executeEval,
339-
executeEntrypoint,
340-
executeRepl,
341-
executeStdin,
342338
cwd,
343-
scriptPath,
344339
};
345340
}
346341

@@ -372,7 +367,15 @@ function phase3(payload: BootstrapState) {
372367
esm,
373368
experimentalSpecifierResolution,
374369
} = payload.parseArgvResult;
375-
const { cwd, scriptPath } = payload.phase2Result!;
370+
const { cwd } = payload.phase2Result!;
371+
372+
// NOTE: When we transition to a child process for ESM, the entry-point script determined
373+
// here might not be the one used later in `phase4`. This can happen when we execute the
374+
// original entry-point but then the process forks itself using e.g. `child_process.fork`.
375+
// We will always use the original TS project in forked processes anyway, so it is
376+
// expected and acceptable to retrieve the entry-point information here in `phase2`.
377+
// See: https://github.com/TypeStrong/ts-node/issues/1812.
378+
const { entryPointPath } = getEntryPointInfo(payload);
376379

377380
const preloadedConfig = findAndReadConfig({
378381
cwd,
@@ -387,7 +390,12 @@ function phase3(payload: BootstrapState) {
387390
compilerHost,
388391
ignore,
389392
logError,
390-
projectSearchDir: getProjectSearchDir(cwd, scriptMode, cwdMode, scriptPath),
393+
projectSearchDir: getProjectSearchDir(
394+
cwd,
395+
scriptMode,
396+
cwdMode,
397+
entryPointPath
398+
),
391399
project,
392400
skipProject,
393401
skipIgnore,
@@ -403,23 +411,77 @@ function phase3(payload: BootstrapState) {
403411
experimentalSpecifierResolution as ExperimentalSpecifierResolution,
404412
});
405413

414+
// If ESM is enabled through the parsed tsconfig, stage4 should be run in a child
415+
// process with the ESM loaders configured.
406416
if (preloadedConfig.options.esm) payload.shouldUseChildProcess = true;
417+
407418
return { preloadedConfig };
408419
}
409420

421+
/**
422+
* Determines the entry-point information from the argv and phase2 result. This
423+
* method will be invoked in two places:
424+
*
425+
* 1. In phase 3 to be able to find a project from the potential entry-point script.
426+
* 2. In phase 4 to determine the actual entry-point script.
427+
*
428+
* Note that we need to explicitly re-resolve the entry-point information in the final
429+
* stage because the previous stage information could be modified when the bootstrap
430+
* invocation transitioned into a child process for ESM.
431+
*
432+
* Stages before (phase 4) can and will be cached by the child process through the Brotli
433+
* configuration and entry-point information is only reliable in the final phase. More
434+
* details can be found in here: https://github.com/TypeStrong/ts-node/issues/1812.
435+
*/
436+
function getEntryPointInfo(state: BootstrapState) {
437+
const { code, interactive, restArgs } = state.parseArgvResult!;
438+
const { cwd } = state.phase2Result!;
439+
const { isCli } = state;
440+
441+
// Figure out which we are executing: piped stdin, --eval, REPL, and/or entrypoint
442+
// This is complicated because node's behavior is complicated
443+
// `node -e code -i ./script.js` ignores -e
444+
const executeEval = code != null && !(interactive && restArgs.length);
445+
const executeEntrypoint = !executeEval && restArgs.length > 0;
446+
const executeRepl =
447+
!executeEntrypoint &&
448+
(interactive || (process.stdin.isTTY && !executeEval));
449+
const executeStdin = !executeEval && !executeRepl && !executeEntrypoint;
450+
451+
/**
452+
* Unresolved. May point to a symlink, not realpath. May be missing file extension
453+
* NOTE: resolution relative to cwd option (not `process.cwd()`) is legacy backwards-compat; should be changed in next major: https://github.com/TypeStrong/ts-node/issues/1834
454+
*/
455+
const entryPointPath = executeEntrypoint
456+
? isCli
457+
? resolve(cwd, restArgs[0])
458+
: resolve(restArgs[0])
459+
: undefined;
460+
461+
return {
462+
executeEval,
463+
executeEntrypoint,
464+
executeRepl,
465+
executeStdin,
466+
entryPointPath,
467+
};
468+
}
469+
410470
function phase4(payload: BootstrapState) {
411-
const { isInChildProcess, entrypoint } = payload;
471+
const { isInChildProcess, tsNodeScript } = payload;
412472
const { version, showConfig, restArgs, code, print, argv } =
413473
payload.parseArgvResult;
474+
const { cwd } = payload.phase2Result!;
475+
const { preloadedConfig } = payload.phase3Result!;
476+
414477
const {
478+
entryPointPath,
479+
executeEntrypoint,
415480
executeEval,
416-
cwd,
417-
executeStdin,
418481
executeRepl,
419-
executeEntrypoint,
420-
scriptPath,
421-
} = payload.phase2Result!;
422-
const { preloadedConfig } = payload.phase3Result!;
482+
executeStdin,
483+
} = getEntryPointInfo(payload);
484+
423485
/**
424486
* <repl>, [stdin], and [eval] are all essentially virtual files that do not exist on disc and are backed by a REPL
425487
* service to handle eval-ing of code.
@@ -566,12 +628,13 @@ function phase4(payload: BootstrapState) {
566628

567629
// Prepend `ts-node` arguments to CLI for child processes.
568630
process.execArgv.push(
569-
entrypoint,
631+
tsNodeScript,
570632
...argv.slice(2, argv.length - restArgs.length)
571633
);
572-
// TODO this comes from BoostrapState
634+
635+
// TODO this comes from BootstrapState
573636
process.argv = [process.argv[1]]
574-
.concat(executeEntrypoint ? ([scriptPath] as string[]) : [])
637+
.concat(executeEntrypoint ? ([entryPointPath] as string[]) : [])
575638
.concat(restArgs.slice(executeEntrypoint ? 1 : 0));
576639

577640
// Execute the main contents (either eval, script or piped).

src/child/argv-payload.ts

+18
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import { brotliCompressSync, brotliDecompressSync, constants } from 'zlib';
2+
3+
/** @internal */
4+
export const argPrefix = '--brotli-base64-config=';
5+
6+
/** @internal */
7+
export function compress(object: any) {
8+
return brotliCompressSync(Buffer.from(JSON.stringify(object), 'utf8'), {
9+
[constants.BROTLI_PARAM_QUALITY]: constants.BROTLI_MIN_QUALITY,
10+
}).toString('base64');
11+
}
12+
13+
/** @internal */
14+
export function decompress(str: string) {
15+
return JSON.parse(
16+
brotliDecompressSync(Buffer.from(str, 'base64')).toString()
17+
);
18+
}

src/child/child-entrypoint.ts

+18-10
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,24 @@
11
import { BootstrapState, bootstrap } from '../bin';
2-
import { brotliDecompressSync } from 'zlib';
2+
import { argPrefix, compress, decompress } from './argv-payload';
33

44
const base64ConfigArg = process.argv[2];
5-
const argPrefix = '--brotli-base64-config=';
65
if (!base64ConfigArg.startsWith(argPrefix)) throw new Error('unexpected argv');
76
const base64Payload = base64ConfigArg.slice(argPrefix.length);
8-
const payload = JSON.parse(
9-
brotliDecompressSync(Buffer.from(base64Payload, 'base64')).toString()
10-
) as BootstrapState;
11-
payload.isInChildProcess = true;
12-
payload.entrypoint = __filename;
13-
payload.parseArgvResult.argv = process.argv;
14-
payload.parseArgvResult.restArgs = process.argv.slice(3);
7+
const state = decompress(base64Payload) as BootstrapState;
158

16-
bootstrap(payload);
9+
state.isInChildProcess = true;
10+
state.tsNodeScript = __filename;
11+
state.parseArgvResult.argv = process.argv;
12+
state.parseArgvResult.restArgs = process.argv.slice(3);
13+
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);

src/child/spawn-child.ts

+8-7
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,15 @@
11
import type { BootstrapState } from '../bin';
22
import { spawn } from 'child_process';
3-
import { brotliCompressSync } from 'zlib';
43
import { pathToFileURL } from 'url';
54
import { versionGteLt } from '../util';
5+
import { argPrefix, compress } from './argv-payload';
66

7-
const argPrefix = '--brotli-base64-config=';
8-
9-
/** @internal */
7+
/**
8+
* @internal
9+
* @param state Bootstrap state to be transferred into the child process.
10+
* @param targetCwd Working directory to be preserved when transitioning to
11+
* the child process.
12+
*/
1013
export function callInChild(state: BootstrapState) {
1114
if (!versionGteLt(process.versions.node, '12.17.0')) {
1215
throw new Error(
@@ -22,9 +25,7 @@ export function callInChild(state: BootstrapState) {
2225
// Node on Windows doesn't like `c:\` absolute paths here; must be `file:///c:/`
2326
pathToFileURL(require.resolve('../../child-loader.mjs')).toString(),
2427
require.resolve('./child-entrypoint.js'),
25-
`${argPrefix}${brotliCompressSync(
26-
Buffer.from(JSON.stringify(state), 'utf8')
27-
).toString('base64')}`,
28+
`${argPrefix}${compress(state)}`,
2829
...state.parseArgvResult.restArgs,
2930
],
3031
{

src/test/esm-loader.spec.ts

+48
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import {
2222
TEST_DIR,
2323
tsSupportsImportAssertions,
2424
tsSupportsResolveJsonModule,
25+
tsSupportsStableNodeNextNode16,
2526
} from './helpers';
2627
import { createExec, createSpawn, ExecReturn } from './exec-helpers';
2728
import { join, resolve } from 'path';
@@ -358,6 +359,53 @@ test.suite('esm', (test) => {
358359
});
359360
}
360361

362+
test.suite('esm child process working directory', (test) => {
363+
test('should have the correct working directory in the user entry-point', async () => {
364+
const { err, stdout, stderr } = await exec(
365+
`${BIN_PATH} --esm --cwd ./esm/ index.ts`,
366+
{
367+
cwd: resolve(TEST_DIR, 'working-dir'),
368+
}
369+
);
370+
371+
expect(err).toBe(null);
372+
expect(stdout.trim()).toBe('Passing');
373+
expect(stderr).toBe('');
374+
});
375+
});
376+
377+
test.suite('esm child process and forking', (test) => {
378+
test('should be able to fork vanilla NodeJS script', async () => {
379+
const { err, stdout, stderr } = await exec(
380+
`${BIN_PATH} --esm --cwd ./esm-child-process/ ./process-forking-js/index.ts`
381+
);
382+
383+
expect(err).toBe(null);
384+
expect(stdout.trim()).toBe('Passing: from main');
385+
expect(stderr).toBe('');
386+
});
387+
388+
test('should be able to fork TypeScript script', async () => {
389+
const { err, stdout, stderr } = await exec(
390+
`${BIN_PATH} --esm --cwd ./esm-child-process/ ./process-forking-ts/index.ts`
391+
);
392+
393+
expect(err).toBe(null);
394+
expect(stdout.trim()).toBe('Passing: from main');
395+
expect(stderr).toBe('');
396+
});
397+
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+
);
402+
403+
expect(err).toBe(null);
404+
expect(stdout.trim()).toBe('Passing: from main');
405+
expect(stderr).toBe('');
406+
});
407+
});
408+
361409
test.suite('parent passes signals to child', (test) => {
362410
test.runSerially();
363411

src/test/index.spec.ts

+27
Original file line numberDiff line numberDiff line change
@@ -617,6 +617,33 @@ test.suite('ts-node', (test) => {
617617
}
618618
});
619619

620+
test('should have the correct working directory in the user entry-point', async () => {
621+
const { err, stdout, stderr } = await exec(
622+
`${BIN_PATH} --cwd ./cjs index.ts`,
623+
{
624+
cwd: resolve(TEST_DIR, 'working-dir'),
625+
}
626+
);
627+
628+
expect(err).toBe(null);
629+
expect(stdout.trim()).toBe('Passing');
630+
expect(stderr).toBe('');
631+
});
632+
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+
);
641+
642+
expect(err).toBe(null);
643+
expect(stdout.trim()).toBe('Passing: from main');
644+
expect(stderr).toBe('');
645+
});
646+
620647
test.suite('should read ts-node options from tsconfig.json', (test) => {
621648
const BIN_EXEC = `"${BIN_PATH}" --project tsconfig-options/tsconfig.json`;
622649

0 commit comments

Comments
 (0)