Skip to content

Commit ef6e5b1

Browse files
authored
feat: faster, lazy-friendly runtime loading (#4181)
Instead of eagerly loading each type from the assemblies in order to annotate the constructors with jsii fully qualified type names (FQNs), leverage the `jsii.rtti` data that is injected by the `jsii` compiler since release `1.19.0`. This should make the jsii runtimes friendlier to large libraries that include lazy-loading provisions, such as the `aws-cdk-lib`. --- In addition to this, `JSII_RUNTIME_PACKAGE_CACHE` was flipped from opt-in to opt-out (set it to any value other than `enabled` to disable), and the `@jsii/runtime` entry point now sets `--preserve-symlinks` so that we can symbolically link packages from the cache instead of copying them around, which is significantly faster. --- Finally, the jsii kernel had not opted out of the assembly validation feature, which is redundant in the majority of scenarios, and is quite time-consuming (~500ms for `aws-cdk-lib`)... So also opting out, but allowing users to opt-back-in via an environment variable. --- Example on a "simple" repro via `aws-cdk-lib`: Before: ``` [@jsii/kernel:timing] tar.extract(<redacted>/.venv-vanilla/lib/python3.11/site-packages/aws_cdk/_jsii/[email protected]) => <redacted>/jsii-kernel-Xxp43L/node_modules/aws-cdk-lib: 1.909s [@jsii/kernel:timing] loadAssemblyFromPath(<redacted>/jsii-kernel-Xxp43L/node_modules/aws-cdk-lib): 383.8ms [@jsii/kernel:timing] require(<redacted>/jsii-kernel-Xxp43L/node_modules/aws-cdk-lib): 630.081ms [@jsii/kernel:timing] registerAssembly({ name: aws-cdk-lib, types: 10957 }): 8.452ms [@jsii/kernel:timing] load({ "name": "aws-cdk-lib", "version": "2.87.0", "tarball": "<redacted>/.venv-vanilla/lib/python3.11/site-packages/aws_cdk/_jsii/[email protected]", "api": "load" }): 2.933s ``` After: ``` [@jsii/kernel:timing] tar.extract(<redacted>/.venv-lazy/lib/python3.11/site-packages/aws_cdk/_jsii/[email protected]) => <redacted>/jsii-kernel-eAOMah/node_modules/aws-cdk-lib: 12.247ms [@jsii/kernel:timing] loadAssemblyFromPath(<redacted>/jsii-kernel-eAOMah/node_modules/aws-cdk-lib): 388.388ms [@jsii/kernel:timing] require(<redacted>/jsii-kernel-eAOMah/node_modules/aws-cdk-lib/lazy-index.js): 132.801ms [@jsii/kernel:timing] registerAssembly({ name: aws-cdk-lib, types: 10957 }): 0.009ms [@jsii/kernel:timing] load({ "name": "aws-cdk-lib", "version": "2.87.0", "tarball": "<redacted>/.venv-lazy/lib/python3.11/site-packages/aws_cdk/_jsii/[email protected]", "api": "load" }): 537.449ms ``` --- By submitting this pull request, I confirm that my contribution is made under the terms of the [Apache 2.0 license]. [Apache 2.0 license]: https://www.apache.org/licenses/LICENSE-2.0
1 parent cde3db6 commit ef6e5b1

File tree

23 files changed

+687
-160
lines changed

23 files changed

+687
-160
lines changed

CONTRIBUTING.md

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,71 @@ Each one of these scripts can be executed either from the root of the repo using
8989
`npx lerna run <script> --scope <package>` or from individual modules using
9090
`yarn <script>`.
9191

92+
#### Reproducting Bugs (Test-Driven Solving)
93+
94+
Troubleshooting bugs usually starts with adding a new test that demonstrates the
95+
faulty behavior, then modifying implementations until the test passes.
96+
97+
The `jsii-calc` and `@scope/*` packages are used to test expected brhavior from
98+
the compiler (note that the [aws/jsii-compiler](github.com/aws/jsii-compiler)
99+
repository as a separate copy of these under the `fixtures` directory), as well
100+
as downstream tooling (`jsii-pacmak`, `jsii-rosetta`, etc...). Each language
101+
runtime has its own test suite, within which is a _compliance_ suite that tests
102+
the same behaviors in all languages, and which should contain tests related to
103+
behavior that isn't strictly specific to the given language.
104+
105+
The `yarn test:update` script in each package runs all tests and updates
106+
snapshots when necessary. It is usually necessary to run this script at least in
107+
`jsii-pacmak` and `jsii-reflect` after changing code in the `jsii-calc` or
108+
`@scope/*` packages.
109+
110+
#### Debugging runtime behavior
111+
112+
Cross-language runtime behavior can be challenging to debug, as data is passed
113+
across process boundaries through Inter-Process Communication (IPC) channels.
114+
Further complicating things, the `@jsii/runtime` library packaged in the various
115+
language runtimes is bundled (by `webpack`), which can make the Javascript
116+
runtime code more complicated to follow.
117+
118+
Setting various environment variables can help understanding what is happening
119+
better:
120+
121+
- `JSII_DEBUG=1` turns on verbose debug logging, which will cause the program to
122+
emit extensive IPC tracing information to `STDERR`. This information can help
123+
identify where things start to behave in unexpected ways, but can be a little
124+
difficult to digest... One may want to refer to the [kernel API][kernel-api]
125+
documentation to make sense of those traces.
126+
127+
- `JSII_DEBUG_TIMING=1` turns on specific timing information from the
128+
`@jsii/kernel` high level API processing, which can be useful to narrow down
129+
the possible causes for performance issues.
130+
131+
- `JSII_RUNTIME` can be set to point to the `bin/jsii-runtime` script within the
132+
`@jsii/runtime` package in order to use a local, non-`webpack`ed version of
133+
the runtime program. This can be particularly helpful when trying to diagnose
134+
a problem within a debugger session.
135+
136+
- `NODE_OPTIONS` can be used to configure specific behaviors of the underlying
137+
`node` runtime, such as specifying `--inspect-brk` to cause the node process
138+
to wait for a debugger to attach before proceeding. This is useful to attach
139+
Node dev tools to the runtime as it starts in order to use its debugger.
140+
141+
The [Visual Studio Code](https://code.visualstudio.com) _JavaScript Debug
142+
Terminal_ feature can be particularly useful paired with appropriate
143+
`JSII_RUNTIME` setting to run arbitrary jsii programs, automatically attaching
144+
the VSCode debugger at startup. These terminals inject a specially crafted
145+
`NODE_OPTIONS` variable that allows the VSCode debugger to consistently attach
146+
to all `node` processes spawned within its context, including child processes
147+
(which can be problematic when running with `--inspect-brk`, as the default
148+
debugger interface's port can only be used by one process at a time).
149+
150+
Finally, the `debugger` Javascript statement can be added anywhere in the
151+
runtime code or tested libraries in order to cause debuggers (if attached) to
152+
pause. This can be easier (and more reliable) to set up than traditional
153+
conditional break points.
154+
155+
[kernel-api]: https://aws.github.io/jsii/specification/3-kernel-api/
156+
92157
#### Linting & Formatting
93158

94159
Eslint and Prettier are used to lint and format our typescript code. The `lint`

packages/@jsii/kernel/src/kernel.ts

Lines changed: 53 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,10 @@ export class Kernel {
4343
* Set to true for timing data to be emitted.
4444
*/
4545
public debugTimingEnabled = false;
46+
/**
47+
* Set to true to validate assemblies upon loading (slow).
48+
*/
49+
public validateAssemblies = false;
4650

4751
readonly #assemblies = new Map<string, Assembly>();
4852
readonly #objects = new ObjectTable(this.#typeInfoForFqn.bind(this));
@@ -69,6 +73,7 @@ export class Kernel {
6973
this.#serializerHost = {
7074
objects: this.#objects,
7175
debug: this.#debug.bind(this),
76+
isVisibleType: this.#isVisibleType.bind(this),
7277
findSymbol: this.#findSymbol.bind(this),
7378
lookupType: this.#typeInfoForFqn.bind(this),
7479
};
@@ -147,10 +152,10 @@ export class Kernel {
147152
}
148153

149154
// read .jsii metadata from the root of the package
150-
let assmSpec;
155+
let assmSpec: spec.Assembly;
151156
try {
152157
assmSpec = this.#debugTime(
153-
() => spec.loadAssemblyFromPath(packageDir),
158+
() => spec.loadAssemblyFromPath(packageDir, this.validateAssemblies),
154159
`loadAssemblyFromPath(${packageDir})`,
155160
);
156161
} catch (e: any) {
@@ -159,10 +164,15 @@ export class Kernel {
159164
);
160165
}
161166

167+
// We do a `require.resolve` call, as otherwise, requiring with a directory will cause any `exports` from
168+
// `package.json` to be ignored, preventing injection of a "lazy index" entry point.
169+
const entryPoint = this.#require!.resolve(assmSpec.name, {
170+
paths: [this.#installDir!],
171+
});
162172
// load the module and capture its closure
163173
const closure = this.#debugTime(
164-
() => this.#require!(packageDir),
165-
`require(${packageDir})`,
174+
() => this.#require!(entryPoint),
175+
`require(${entryPoint})`,
166176
);
167177
const assm = new Assembly(assmSpec, closure);
168178
this.#debugTime(
@@ -333,7 +343,7 @@ export class Kernel {
333343
throw new JsiiFault(`${method} is an async method, use "begin" instead`);
334344
}
335345

336-
const fqn = jsiiTypeFqn(obj);
346+
const fqn = jsiiTypeFqn(obj, this.#isVisibleType.bind(this));
337347
const ret = this.#ensureSync(
338348
`method '${objref[TOKEN_REF]}.${method}'`,
339349
() => {
@@ -420,7 +430,7 @@ export class Kernel {
420430
throw new JsiiFault(`Method ${method} is expected to be an async method`);
421431
}
422432

423-
const fqn = jsiiTypeFqn(obj);
433+
const fqn = jsiiTypeFqn(obj, this.#isVisibleType.bind(this));
424434

425435
const promise = fn.apply(
426436
obj,
@@ -568,6 +578,21 @@ export class Kernel {
568578
#addAssembly(assm: Assembly) {
569579
this.#assemblies.set(assm.metadata.name, assm);
570580

581+
// We can use jsii runtime type information from jsii 1.19.0 onwards... Note that a version of
582+
// 0.0.0 means we are assessing against a development tree, which is newer...
583+
const jsiiVersion = assm.metadata.jsiiVersion.split(' ', 1)[0];
584+
const [jsiiMajor, jsiiMinor, _jsiiPatch, ..._rest] = jsiiVersion
585+
.split('.')
586+
.map((str) => parseInt(str, 10));
587+
if (
588+
jsiiVersion === '0.0.0' ||
589+
jsiiMajor > 1 ||
590+
(jsiiMajor === 1 && jsiiMinor >= 19)
591+
) {
592+
this.#debug('Using compiler-woven runtime type information!');
593+
return;
594+
}
595+
571596
// add the __jsii__.fqn property on every constructor. this allows
572597
// traversing between the javascript and jsii worlds given any object.
573598
for (const fqn of Object.keys(assm.metadata.types ?? {})) {
@@ -869,7 +894,7 @@ export class Kernel {
869894
methodInfo: spec.Method,
870895
) {
871896
const methodName = override.method;
872-
const fqn = jsiiTypeFqn(obj);
897+
const fqn = jsiiTypeFqn(obj, this.#isVisibleType.bind(this));
873898
const methodContext = `${methodInfo.async ? 'async ' : ''}method${
874899
fqn ? `${fqn}#` : methodName
875900
}`;
@@ -1029,7 +1054,7 @@ export class Kernel {
10291054
return curr;
10301055
}
10311056

1032-
#typeInfoForFqn(fqn: string): spec.Type {
1057+
#typeInfoForFqn(fqn: spec.FQN): spec.Type {
10331058
const components = fqn.split('.');
10341059
const moduleName = components[0];
10351060

@@ -1047,6 +1072,26 @@ export class Kernel {
10471072
return fqnInfo;
10481073
}
10491074

1075+
/**
1076+
* Determines whether the provided FQN corresponds to a valid, exported type
1077+
* from any currently loaded assembly.
1078+
*
1079+
* @param fqn the tested FQN.
1080+
*
1081+
* @returns `true` IIF the FQN corresponds to a know exported type.
1082+
*/
1083+
#isVisibleType(fqn: spec.FQN): boolean {
1084+
try {
1085+
/* ignored */ this.#typeInfoForFqn(fqn);
1086+
return true;
1087+
} catch (e) {
1088+
if (e instanceof JsiiFault) {
1089+
return false;
1090+
}
1091+
throw e;
1092+
}
1093+
}
1094+
10501095
#typeInfoForMethod(
10511096
methodName: string,
10521097
fqn: string,

packages/@jsii/kernel/src/link.ts

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,19 @@
1-
import { copyFileSync, linkSync, mkdirSync, readdirSync, statSync } from 'fs';
2-
import { join } from 'path';
1+
import {
2+
copyFileSync,
3+
linkSync,
4+
mkdirSync,
5+
readdirSync,
6+
statSync,
7+
symlinkSync,
8+
} from 'fs';
9+
import { dirname, join } from 'path';
10+
11+
/**
12+
* If `node` is started with `--preserve-symlinks`, the module loaded will
13+
* preserve symbolic links instead of resolving them, making it possible to
14+
* symbolically link packages in place instead of fully copying them.
15+
*/
16+
const PRESERVE_SYMLINKS = process.execArgv.includes('--preserve-symlinks');
317

418
/**
519
* Creates directories containing hard links if possible, and falls back on
@@ -9,6 +23,12 @@ import { join } from 'path';
923
* @param destination is the new file or directory to create.
1024
*/
1125
export function link(existing: string, destination: string): void {
26+
if (PRESERVE_SYMLINKS) {
27+
mkdirSync(dirname(destination), { recursive: true });
28+
symlinkSync(existing, destination);
29+
return;
30+
}
31+
1232
const stat = statSync(existing);
1333
if (!stat.isDirectory()) {
1434
try {

0 commit comments

Comments
 (0)