Skip to content

Commit 896d688

Browse files
RomainMullermergify[bot]
authored andcommitted
fix(runtime): make kernel 'load' operation synchronous (#951)
The `jsii-runtime` process cannot perform async operations while waiting for a (synchronous) callback to return (the response flow is otherwise ambiguous). In order to allow loading of dependencies within a calllback flow, the `load` kernel API needed to be made syncrhonous. The reason why this is needed is because the host languages runtime libraries may be required to load dependencies at the last minute, for it may not be possible to determine what dependencies are required before a callback is executed. Introduced a (unit) test to confirm the `jsii-runtime` is indeed able to perform `load` operations within a callback context.
1 parent 526509c commit 896d688

File tree

12 files changed

+318
-88
lines changed

12 files changed

+318
-88
lines changed

packages/jsii-kernel/lib/kernel.ts

Lines changed: 11 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -56,16 +56,16 @@ export class Kernel {
5656
});
5757
}
5858

59-
public async load(req: api.LoadRequest): Promise<api.LoadResponse> {
59+
public load(req: api.LoadRequest): api.LoadResponse {
6060
this._debug('load', req);
6161

6262
if ('assembly' in req) {
6363
throw new Error('`assembly` field is deprecated for "load", use `name`, `version` and `tarball` instead');
6464
}
6565

6666
if (!this.installDir) {
67-
this.installDir = await fs.mkdtemp(path.join(os.tmpdir(), 'jsii-kernel-'));
68-
await fs.mkdirp(path.join(this.installDir, 'node_modules'));
67+
this.installDir = fs.mkdtempSync(path.join(os.tmpdir(), 'jsii-kernel-'));
68+
fs.mkdirpSync(path.join(this.installDir, 'node_modules'));
6969
this._debug('creating jsii-kernel modules workdir:', this.installDir);
7070

7171
process.on('exit', () => {
@@ -81,9 +81,9 @@ export class Kernel {
8181

8282
// check if we already have such a module
8383
const packageDir = path.join(this.installDir, 'node_modules', pkgname);
84-
if (await fs.pathExists(packageDir)) {
84+
if (fs.pathExistsSync(packageDir)) {
8585
// module exists, verify version
86-
const epkg = await fs.readJson(path.join(packageDir, 'package.json'));
86+
const epkg = fs.readJsonSync(path.join(packageDir, 'package.json'));
8787
if (epkg.version !== pkgver) {
8888
throw new Error(`Multiple versions ${pkgver} and ${epkg.version} of the `
8989
+ `package '${pkgname}' cannot be loaded together since this is unsupported by `
@@ -101,19 +101,19 @@ export class Kernel {
101101
}
102102
// untar the archive to a staging directory, read the jsii spec from it
103103
// and then move it to the node_modules directory of the kernel.
104-
const staging = await fs.mkdtemp(path.join(os.tmpdir(), 'jsii-kernel-install-staging-'));
104+
const staging = fs.mkdtempSync(path.join(os.tmpdir(), 'jsii-kernel-install-staging-'));
105105
try {
106-
await tar.extract({ strict: true, file: req.tarball, cwd: staging });
106+
tar.extract({ strict: true, file: req.tarball, cwd: staging, sync: true });
107107

108108
// read .jsii metadata from the root of the package
109109
const jsiiMetadataFile = path.join(staging, 'package', spec.SPEC_FILE_NAME);
110-
if (!await fs.pathExists(jsiiMetadataFile)) {
110+
if (!fs.pathExistsSync(jsiiMetadataFile)) {
111111
throw new Error(`Package tarball ${req.tarball} must have a file named ${spec.SPEC_FILE_NAME} at the root`);
112112
}
113-
const assmSpec = await fs.readJson(jsiiMetadataFile) as spec.Assembly;
113+
const assmSpec = fs.readJsonSync(jsiiMetadataFile) as spec.Assembly;
114114

115115
// "install" to "node_modules" directory
116-
await fs.move(path.join(staging, 'package'), packageDir);
116+
fs.moveSync(path.join(staging, 'package'), packageDir);
117117

118118
// load the module and capture it's closure
119119
const closure = this._execute(`require(String.raw\`${packageDir}\`)`, packageDir);
@@ -126,7 +126,7 @@ export class Kernel {
126126
};
127127
} finally {
128128
this._debug('removing staging directory:', staging);
129-
await fs.remove(staging);
129+
fs.removeSync(staging);
130130
}
131131

132132
}

packages/jsii-kernel/test/kernel.test.ts

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -814,17 +814,18 @@ defineTest('fails: static methods - not static', (sandbox) => {
814814
});
815815

816816
defineTest('loading a module twice idepotently succeeds', async (sandbox) => {
817-
await sandbox.load({
817+
sandbox.load({
818818
tarball: await preparePackage('jsii-calc', false),
819819
name: 'jsii-calc',
820820
version: calcVersion
821821
});
822822
});
823823

824-
defineTest('fails if trying to load two different versions of the same module', async (sandbox) =>
825-
expect(sandbox.load({ tarball: await preparePackage('jsii-calc', false), name: 'jsii-calc', version: '99.999.9' }))
826-
.rejects.toThrow(/Multiple versions .+ and .+ of the package 'jsii-calc' cannot be loaded together/)
827-
);
824+
defineTest('fails if trying to load two different versions of the same module', async (sandbox) => {
825+
const tarball = await preparePackage('jsii-calc', false);
826+
return expect(() => sandbox.load({ tarball, name: 'jsii-calc', version: '99.999.9' }))
827+
.toThrow(/Multiple versions .+ and .+ of the package 'jsii-calc' cannot be loaded together/)
828+
});
828829

829830
defineTest('node.js standard library', async (sandbox) => {
830831
const objref = sandbox.create({ fqn: 'jsii-calc.NodeStandardLibrary' });
@@ -1221,9 +1222,9 @@ async function createCalculatorSandbox(name: string) {
12211222

12221223
sandbox.traceEnabled = `${process.env.JSII_DEBUG}` === '1';
12231224

1224-
await sandbox.load({ tarball: await preparePackage('@scope/jsii-calc-base'), name: '@scope/jsii-calc-base', version: calcBaseVersion });
1225-
await sandbox.load({ tarball: await preparePackage('@scope/jsii-calc-lib'), name: '@scope/jsii-calc-lib', version: calcLibVersion });
1226-
await sandbox.load({ tarball: await preparePackage('jsii-calc'), name: 'jsii-calc', version: calcVersion });
1225+
sandbox.load({ tarball: await preparePackage('@scope/jsii-calc-base'), name: '@scope/jsii-calc-base', version: calcBaseVersion });
1226+
sandbox.load({ tarball: await preparePackage('@scope/jsii-calc-lib'), name: '@scope/jsii-calc-lib', version: calcLibVersion });
1227+
sandbox.load({ tarball: await preparePackage('jsii-calc'), name: 'jsii-calc', version: calcVersion });
12271228
return sandbox;
12281229
}
12291230

packages/jsii-runtime/.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,3 +8,5 @@ webpack
88
node_modules/
99
.nyc_output/
1010
coverage/
11+
12+
test/_tarballs/

packages/jsii-runtime/.npmignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,3 +9,5 @@ coverage
99
.eslintrc.*
1010
tsconfig.json
1111
*.tsbuildinfo
12+
13+
test/_tarballs/

packages/jsii-runtime/lib/host.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
import { api, Kernel } from 'jsii-kernel';
22
import { Input, InputOutput } from './in-out';
3+
import { EventEmitter } from 'events';
34

45
export class KernelHost {
56
private readonly kernel = new Kernel(this.callbackHandler.bind(this));
7+
private readonly eventEmitter = new EventEmitter();
68

79
public constructor(private readonly inout: InputOutput, private readonly opts: { debug?: boolean, noStack?: boolean } = { }) {
810
this.kernel.traceEnabled = opts.debug ? true : false;
@@ -11,6 +13,7 @@ export class KernelHost {
1113
public run() {
1214
const req = this.inout.read();
1315
if (!req) {
16+
this.eventEmitter.emit('exit');
1417
return; // done
1518
}
1619

@@ -21,6 +24,10 @@ export class KernelHost {
2124
});
2225
}
2326

27+
public on(event: 'exit', listener: () => void) {
28+
this.eventEmitter.on(event, listener);
29+
}
30+
2431
private callbackHandler(callback: api.Callback) {
2532

2633
// write a "callback" response, which is a special response that tells

packages/jsii-runtime/lib/index.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,2 @@
1-
// this module doesn't export any symbols
2-
1+
export * from './host';
2+
export * from './in-out';

packages/jsii-runtime/package.json

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,8 +28,8 @@
2828
"build": "tsc --build && chmod +x bin/jsii-runtime && /bin/bash ./bundle.sh && npm run lint",
2929
"watch": "tsc --build -w",
3030
"lint": "eslint . --ext .js,.ts --ignore-path=.gitignore",
31-
"test": "/bin/bash test/playback-test.sh && node test/stress-test.js",
32-
"test:update": "UPDATE_DIFF=1 npm run test",
31+
"test": "jest",
32+
"test:update": "jest -u",
3333
"package": "package-js"
3434
},
3535
"dependencies": {
@@ -39,6 +39,7 @@
3939
"devDependencies": {
4040
"@scope/jsii-calc-base": "^0.20.1",
4141
"@scope/jsii-calc-lib": "^0.20.1",
42+
"@types/jest": "^24.0.22",
4243
"@typescript-eslint/eslint-plugin": "^2.6.1",
4344
"@typescript-eslint/parser": "^2.6.1",
4445
"eslint": "^6.6.0",
@@ -51,5 +52,30 @@
5152
"wasm-loader": "^1.3.0",
5253
"webpack": "^4.41.2",
5354
"webpack-cli": "^3.3.10"
55+
},
56+
"jest": {
57+
"collectCoverage": true,
58+
"collectCoverageFrom": [
59+
"**/bin/**/*.js",
60+
"**/lib/**/*.js"
61+
],
62+
"coverageReporters": [
63+
"lcov",
64+
"text"
65+
],
66+
"coverageThreshold": {
67+
"global": {
68+
"branches": 45,
69+
"statements": 57
70+
}
71+
},
72+
"errorOnDeprecated": true,
73+
"setupFilesAfterEnv": [
74+
"jest-expect-message"
75+
],
76+
"testEnvironment": "node",
77+
"testMatch": [
78+
"**/?(*.)+(spec|test).js"
79+
]
5480
}
5581
}
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
// Jest Snapshot v1, https://goo.gl/fbAQLP
2+
3+
exports[`can load libraries from within a callback 1`] = `
4+
Object {
5+
"ok": Object {
6+
"assembly": "@scope/jsii-calc-base",
7+
"types": "*redacted*",
8+
},
9+
}
10+
`;
11+
12+
exports[`can load libraries from within a callback 2`] = `
13+
Object {
14+
"ok": Object {
15+
"assembly": "@scope/jsii-calc-lib",
16+
"types": "*redacted*",
17+
},
18+
}
19+
`;
20+
21+
exports[`can load libraries from within a callback 3`] = `
22+
Object {
23+
"ok": Object {
24+
"$jsii.byref": "Object@10000",
25+
"$jsii.interfaces": Array [
26+
"@scope/jsii-calc-lib.IFriendly",
27+
],
28+
},
29+
}
30+
`;
31+
32+
exports[`can load libraries from within a callback 4`] = `
33+
Object {
34+
"callback": Object {
35+
"cbid": "jsii::callback::20000",
36+
"cookie": undefined,
37+
"invoke": Object {
38+
"args": Array [],
39+
"method": "hello",
40+
"objref": Object {
41+
"$jsii.byref": "Object@10000",
42+
"$jsii.interfaces": Array [
43+
"@scope/jsii-calc-lib.IFriendly",
44+
],
45+
},
46+
},
47+
},
48+
}
49+
`;
50+
51+
exports[`can load libraries from within a callback 5`] = `
52+
Object {
53+
"ok": Object {
54+
"assembly": "jsii-calc",
55+
"types": "*redacted*",
56+
},
57+
}
58+
`;
59+
60+
exports[`can load libraries from within a callback 6`] = `
61+
Object {
62+
"ok": Object {
63+
"result": "SUCCESS!",
64+
},
65+
}
66+
`;
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
import child = require('child_process');
2+
import fs = require('fs');
3+
import { api } from 'jsii-kernel';
4+
import spec = require('jsii-spec');
5+
import path = require('path');
6+
import { KernelHost, InputOutput, Input, Output } from '../lib';
7+
8+
test('can load libraries from within a callback', () => {
9+
const inout = new TestInputOutput(
10+
[
11+
{ api: 'load', ...loadRequest('@scope/jsii-calc-base') },
12+
{ api: 'load', ...loadRequest('@scope/jsii-calc-lib') },
13+
{ api: 'create', fqn: 'Object', interfaces: ['@scope/jsii-calc-lib.IFriendly'], overrides: [{ method: 'hello' }] },
14+
{ api: 'invoke', objref: { [api.TOKEN_REF]: 'Object@10000' }, method: 'hello' },
15+
{ api: 'load', ...loadRequest('jsii-calc') },
16+
{ complete: { cbid: 'jsii::callback::20000', result: 'SUCCESS!' } },
17+
]
18+
);
19+
const host = new KernelHost(inout, { noStack: true, debug: false });
20+
return new Promise<void>(ok => {
21+
host.on('exit', () => ok(inout.expectCompleted()));
22+
host.run();
23+
});
24+
});
25+
26+
class TestInputOutput extends InputOutput {
27+
private readonly inputCommands: Input[];
28+
29+
public constructor(inputCommands: Input[], private readonly allowErrors = false) {
30+
super();
31+
this.inputCommands = inputCommands.reverse();
32+
}
33+
34+
public read(): Input | undefined {
35+
return this.inputCommands.pop();
36+
}
37+
38+
public write(obj: Output): void {
39+
if (!this.allowErrors) {
40+
expect(obj).not.toHaveProperty('error');
41+
}
42+
if ('ok' in obj && 'assembly' in obj.ok) {
43+
// Removing the type count as this is subject to change!
44+
(obj.ok as any).types = '*redacted*';
45+
}
46+
expect(obj).toMatchSnapshot();
47+
}
48+
49+
/**
50+
* Validates that all inputs have been consumed, and all expected outputs have been checked.
51+
*/
52+
public expectCompleted(): void {
53+
expect(this.inputCommands).toEqual([]);
54+
}
55+
}
56+
57+
function loadRequest(library: string): api.LoadRequest {
58+
const assembly = loadAssembly();
59+
const tarball = path.join(__dirname, '_tarballs', library, `${assembly.fingerprint.replace('/', '_')}.tgz`);
60+
if (!fs.existsSync(tarball)) {
61+
packageLibrary(tarball);
62+
}
63+
return {
64+
name: assembly.name,
65+
version: assembly.version,
66+
tarball,
67+
};
68+
69+
function loadAssembly(): spec.Assembly {
70+
const assemblyFile = path.resolve(require.resolve(`${library}/package.json`), '..', '.jsii');
71+
return JSON.parse(fs.readFileSync(assemblyFile, { encoding: 'utf-8' }));
72+
}
73+
74+
function packageLibrary(target: string): void {
75+
const targetDir = path.dirname(target);
76+
fs.mkdirSync(targetDir, { recursive: true });
77+
const result = child.spawnSync('npm', ['pack', path.dirname(require.resolve(`${library}/package.json`))], { cwd: targetDir, stdio: ['inherit', 'pipe', 'pipe'] });
78+
if (result.error) {
79+
throw result.error;
80+
}
81+
if (result.status !== 0) {
82+
console.error(result.stderr.toString('utf-8'));
83+
throw new Error(`Unable to 'npm pack' ${library}: process ${result.signal != null ? `killed by ${result.signal}` : `exited with code ${result.status}`}`);
84+
}
85+
fs.renameSync(path.join(targetDir, result.stdout.toString('utf-8').trim()), target);
86+
}
87+
}

0 commit comments

Comments
 (0)