Fix a hang in nodejs remote components when an error is thrown within an apply (#7365)

This commit is contained in:
Evan Boyle 2021-06-25 18:41:54 -07:00 committed by GitHub
parent 3cdfbf2a71
commit c37cbc998b
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
15 changed files with 187 additions and 2 deletions

View file

@ -22,3 +22,6 @@
- [multilang/python] - Fix nested module generation.
[#7353](https://github.com/pulumi/pulumi/pull/7353)
- [multilang/nodejs] - Fix a hang when an error is thrown within an apply in a remote component.
[#7365](https://github.com/pulumi/pulumi/pull/7365)

View file

@ -63,6 +63,7 @@ test_build:: $(SUB_PROJECTS:%=%_install)
cd tests/integration/construct_component_unknown/testcomponent-go && go build -o pulumi-resource-testcomponent
cd tests/integration/component_provider_schema/testcomponent && yarn install && yarn link @pulumi/pulumi && yarn run tsc
cd tests/integration/component_provider_schema/testcomponent-go && go build -o pulumi-resource-testcomponent
cd tests/integration/construct_component_error_apply/testcomponent && yarn install && yarn link @pulumi/pulumi && yarn run tsc
test_all:: build test_build $(SUB_PROJECTS:%=%_install)
cd pkg && $(GO_TEST) ${PROJECT_PKGS}

View file

@ -284,6 +284,10 @@
WorkingDirectory="$(TestsDirectory)\integration\construct_component_unknown\testcomponent" />
<Exec Command="yarn link @pulumi/pulumi"
WorkingDirectory="$(TestsDirectory)\integration\construct_component_unknown\testcomponent" />
<Exec Command="yarn install"
WorkingDirectory="$(TestsDirectory)\integration\construct_component_error_apply\testcomponent" />
<Exec Command="yarn link @pulumi/pulumi"
WorkingDirectory="$(TestsDirectory)\integration\construct_component_error_apply\testcomponent" />
</Target>
<Target Name="TestBuild">
@ -297,6 +301,7 @@
<Exec Command="go build -o pulumi-resource-testcomponent.exe" WorkingDirectory="$(TestsDirectory)\integration\component_provider_schema\testcomponent-go" />
<Exec Command="yarn run tsc" WorkingDirectory="$(TestsDirectory)\integration\construct_component_unknown\testcomponent" />
<Exec Command="go build -o pulumi-resource-testcomponent.exe" WorkingDirectory="$(TestsDirectory)\integration\construct_component_unknown\testcomponent-go" />
<Exec Command="yarn run tsc" WorkingDirectory="$(TestsDirectory)\integration\construct_component_error_apply\testcomponent" />
<!-- Install pulumi SDK into the venv managed by pipenv. -->
<Exec Command="pipenv run pip install -e ."

View file

@ -37,13 +37,15 @@ const statusproto = require("../proto/status_pb.js");
class Server implements grpc.UntypedServiceImplementation {
readonly engineAddr: string;
readonly provider: Provider;
readonly uncaughtErrors: Set<Error>;
/** Queue of construct calls. */
constructQueue = Promise.resolve();
constructor(engineAddr: string, provider: Provider) {
constructor(engineAddr: string, provider: Provider, uncaughtErrors: Set<Error>) {
this.engineAddr = engineAddr;
this.provider = provider;
this.uncaughtErrors = uncaughtErrors;
}
// Satisfy the grpc.UntypedServiceImplementation interface.
@ -267,6 +269,20 @@ class Server implements grpc.UntypedServiceImplementation {
}
async constructImpl(call: any, callback: any): Promise<void> {
// given that construct calls are serialized, we can attach an uncaught handler to pick up exceptions
// in underlying user code. When we catch the error, we need to respond to the gRPC request with the error
// to avoid a hang.
const uncaughtHandler = (err: Error) => {
if (!this.uncaughtErrors.has(err)) {
this.uncaughtErrors.add(err);
}
// bubble the uncaught error in the user code back and terminate the outstanding gRPC request.
callback(err, undefined);
};
process.on("uncaughtException", uncaughtHandler);
// @ts-ignore 'unhandledRejection' will almost always invoke uncaughtHandler with an Error. so
// just suppress the TS strictness here.
process.on("unhandledRejection", uncaughtHandler);
try {
const req: any = call.request;
const type = req.getType();
@ -357,6 +373,10 @@ class Server implements grpc.UntypedServiceImplementation {
} catch (e) {
console.error(`${e}: ${e.stack}`);
callback(e, undefined);
} finally {
// remove these uncaught handlers that are specific to this gRPC callback context
process.off("uncaughtException", uncaughtHandler);
process.off("unhandledRejection", uncaughtHandler);
}
}
@ -477,7 +497,7 @@ export async function main(provider: Provider, args: string[]) {
const server = new grpc.Server({
"grpc.max_receive_message_length": runtime.maxRPCMessageSize,
});
server.addService(provrpc.ResourceProviderService, new Server(engineAddr, provider));
server.addService(provrpc.ResourceProviderService, new Server(engineAddr, provider, uncaughtErrors));
const port: number = await new Promise<number>((resolve, reject) => {
server.bindAsync(`0.0.0.0:0`, grpc.ServerCredentials.createInsecure(), (err, p) => {
if (err) {

View file

@ -0,0 +1,3 @@
/.pulumi/
/bin/
/node_modules/

View file

@ -0,0 +1,3 @@
name: construct_component_nodejs_error_apply
description: A program that constructs remote component resources.
runtime: nodejs

View file

@ -0,0 +1,19 @@
// Copyright 2016-2021, Pulumi Corporation. All rights reserved.
import * as pulumi from "@pulumi/pulumi";
interface ComponentArgs {
foo: pulumi.Input<string>;
}
export class Component extends pulumi.ComponentResource {
public readonly foo!: pulumi.Output<string>;
constructor(name: string, args: ComponentArgs, opts?: pulumi.ComponentResourceOptions) {
const inputs: any = {};
inputs["foo"] = args.foo;
super("testcomponent:index:Component", name, inputs, opts, true);
}
}

View file

@ -0,0 +1,6 @@
// Copyright 2016-2021, Pulumi Corporation. All rights reserved.
import { Component } from "./component";
const componentA = new Component("a", {foo: "bar"});

View file

@ -0,0 +1,10 @@
{
"name": "steps",
"license": "Apache-2.0",
"devDependencies": {
"typescript": "^3.0.0"
},
"peerDependencies": {
"@pulumi/pulumi": "latest"
}
}

View file

@ -0,0 +1,49 @@
// Copyright 2016-2021, Pulumi Corporation. All rights reserved.
import * as pulumi from "@pulumi/pulumi";
import * as provider from "@pulumi/pulumi/provider";
class Component extends pulumi.ComponentResource {
public readonly foo: pulumi.Output<string>;
constructor(name: string, foo: pulumi.Input<string>, opts?: pulumi.ComponentResourceOptions) {
super("testcomponent:index:Component", name, {}, opts);
this.foo = pulumi.output(foo);
this.registerOutputs({
foo: this.foo,
})
}
}
class Provider implements provider.Provider {
public readonly version = "0.0.1";
construct(name: string, type: string, inputs: pulumi.Inputs,
options: pulumi.ComponentResourceOptions): Promise<provider.ConstructResult> {
if (type != "testcomponent:index:Component") {
throw new Error(`unknown resource type ${type}`);
}
const foo = pulumi.output("").apply(a => {
throw new Error("intentional error from within an apply");
return a;
});
const component = new Component(name, foo);
return Promise.resolve({
urn: component.urn,
state: {
foo: component.foo
},
});
}
}
export function main(args: string[]) {
return provider.main(new Provider(), args);
}
main(process.argv.slice(2));

View file

@ -0,0 +1,11 @@
{
"name": "pulumi-resource-testcomponent",
"main": "index.js",
"devDependencies": {
"typescript": "^3.0.0",
"@types/node": "latest"
},
"peerDependencies": {
"@pulumi/pulumi": "latest"
}
}

View file

@ -0,0 +1,3 @@
#!/bin/bash
SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" >/dev/null 2>&1 && pwd )"
node $SCRIPT_DIR/bin $@

View file

@ -0,0 +1,4 @@
@echo off
setlocal
set SCRIPT_DIR=%~dp0
@node "%SCRIPT_DIR%/bin" %*

View file

@ -0,0 +1,20 @@
{
"compilerOptions": {
"strict": true,
"outDir": "bin",
"target": "es2016",
"module": "commonjs",
"moduleResolution": "node",
"declaration": true,
"sourceMap": false,
"stripInternal": true,
"experimentalDecorators": true,
"pretty": true,
"noFallthroughCasesInSwitch": true,
"noImplicitReturns": true,
"forceConsistentCasingInFileNames": true
},
"files": [
"index.ts",
]
}

View file

@ -1017,3 +1017,31 @@ func TestComponentProviderSchemaNode(t *testing.T) {
}
testComponentProviderSchema(t, path)
}
// Test throwing an error within an apply in a remote component written in nodejs.
// The provider should return the error and shutdown gracefully rather than hanging.
func TestConstructNodeErrorApply(t *testing.T) {
dir := "construct_component_error_apply"
componentDir := "testcomponent"
stderr := &bytes.Buffer{}
expectedError := "intentional error from within an apply"
opts := &integration.ProgramTestOptions{
Env: []string{pathEnv(t, filepath.Join(dir, componentDir))},
Dir: filepath.Join(dir, "nodejs"),
Dependencies: []string{"@pulumi/pulumi"},
Quick: true,
NoParallel: true,
Stderr: stderr,
ExpectFailure: true,
ExtraRuntimeValidation: func(t *testing.T, stackInfo integration.RuntimeValidationStackInfo) {
output := stderr.String()
assert.Contains(t, output, expectedError)
},
}
t.Run(componentDir, func(t *testing.T) {
integration.ProgramTest(t, opts)
})
}