Skip to content

Commit f08e939

Browse files
authored
implement debugging support for dotnet (#332)
For dotnet to run with a debugger, we don't need to run the binary under a debugger, but rather the IDEs can just attach to the program. This needs some support from the SDK, so we wait until the debugger is actually attached. Note that this does not seem to work when just running the program using `dotnet run .`, but rather we need to build and publish the program first, and only then we can attach the debugger to it. (It might just be that running the program under the debugger is horribly slow, and thus using `dotnet run` takes more time than I had patience for, but that wouldn't be a good user experience either way). I'm not sure how to write an integration test for this. For Go the debugger starts a tcp server we can communicate with, but not sure what attaching to a PID means in that context/how exactly to do that. I did try it out manually (which was also difficult, since including the local SDK doesn't seem to work with `dotnet build; dotnet <path-to.dll>`, so I ended up just copying the SDK changes to a program 😬) Fixes #333
1 parent cb3c104 commit f08e939

File tree

10 files changed

+348
-41
lines changed

10 files changed

+348
-41
lines changed
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
component: sdk
2+
kind: Improvements
3+
body: Add support for attaching debuggers
4+
time: 2024-09-04T16:20:23.611864018+02:00
5+
custom:
6+
PR: "332"

proto/pulumi/codegen/loader.proto

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,12 +25,23 @@ service Loader {
2525
rpc GetSchema(GetSchemaRequest) returns (GetSchemaResponse) {}
2626
}
2727

28+
// Parameterization specifies the name, version, and value for a parameterized package.
29+
message Parameterization {
30+
string name = 1; // the parameterized package name.
31+
string version = 2; // the parameterized package version.
32+
bytes value = 3; // the parameter value for the parameterized package.
33+
}
34+
2835
// GetSchemaRequest allows the engine to return a schema for a given package and version.
2936
message GetSchemaRequest {
3037
// the package name for the schema being requested.
3138
string package = 1;
3239
// the version for the schema being requested, must be a valid semver or empty.
3340
string version = 2;
41+
// the optional download url for the schema being requested.
42+
string download_url = 3;
43+
// the parameterization for the schema being requested, can be empty.
44+
Parameterization parameterization = 4;
3445
}
3546

3647
// GetSchemaResponse returns the schema data for the requested package.

proto/pulumi/engine.proto

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
syntax = "proto3";
1616

1717
import "google/protobuf/empty.proto";
18+
import "google/protobuf/struct.proto";
1819

1920
package pulumirpc;
2021

@@ -33,6 +34,10 @@ service Engine {
3334

3435
// SetRootResource sets the URN of the root resource.
3536
rpc SetRootResource(SetRootResourceRequest) returns (SetRootResourceResponse) {}
37+
38+
// StartDebugging indicates to the engine that the program has started under a debugger, and the engine
39+
// should notify the user of how to connect to the debugger.
40+
rpc StartDebugging(StartDebuggingRequest) returns (google.protobuf.Empty) {}
3641
}
3742

3843
// LogSeverity is the severity level of a log message. Errors are fatal; all others are informational.
@@ -83,3 +88,10 @@ message SetRootResourceRequest {
8388
message SetRootResourceResponse {
8489
// empty.
8590
}
91+
92+
message StartDebuggingRequest {
93+
// the debug configuration parameters. These are meant to be in the right format for the DAP protocol to consume.
94+
google.protobuf.Struct config = 1;
95+
// the string to display to the user with instructions on how to connect to the debugger.
96+
string message = 2;
97+
}

proto/pulumi/language.proto

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,9 @@ message RunRequest {
133133
string organization = 12; // the organization of the stack being deployed into.
134134
google.protobuf.Struct configPropertyMap = 13; // the configuration variables to apply before running.
135135
ProgramInfo info = 14; // the program info to use to execute the program.
136+
// The target of a codegen.LoaderServer to use for loading schemas.
137+
string loader_target = 15;
138+
bool attach_debugger = 16; // true if the language host is supposed to start the program under a debugger.
136139
}
137140

138141
// RunResponse is the response back from the interpreter/source back to the monitor.
@@ -150,6 +153,8 @@ message InstallDependenciesRequest {
150153
string directory = 1 [deprecated = true]; // the program's working directory.
151154
bool is_terminal = 2; // if we are running in a terminal and should use ANSI codes
152155
ProgramInfo info = 3; // the program info to use to execute the plugin.
156+
bool use_language_version_tools = 4; // if we should use language version tools like pyenv or
157+
// nvm to setup the language version.
153158
}
154159

155160
message InstallDependenciesResponse {
@@ -250,6 +255,9 @@ message GeneratePackageRequest {
250255
// local dependencies to use instead of using the package system. This is a map of package name to a local
251256
// path of a language specific artifact to use for the SDK for that package.
252257
map<string, string> local_dependencies = 5;
258+
// if true generates an SDK appropriate for local usage, this may differ from a standard publishable SDK depending
259+
// on the language.
260+
bool local = 6;
253261
}
254262

255263
message GeneratePackageResponse {

proto/pulumi/provider.proto

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -221,9 +221,14 @@ message CallResponse {
221221
repeated string urns = 1; // A list of URNs this return value depends on.
222222
}
223223

224-
google.protobuf.Struct return = 1; // the returned values, if call was successful.
225-
map<string, ReturnDependencies> returnDependencies = 2; // a map from return value keys to the dependencies of the return value.
226-
repeated CheckFailure failures = 3; // the failures if any arguments didn't pass verification.
224+
google.protobuf.Struct return = 1; // the returned values, if call was successful.
225+
repeated CheckFailure failures = 3; // the failures if any arguments didn't pass verification.
226+
227+
// a map from return value keys to the dependencies of the return value.
228+
//
229+
// returnDependencies will be augmented by the set of dependencies specified in return
230+
// via output property values.
231+
map<string, ReturnDependencies> returnDependencies = 2;
227232
}
228233

229234
message CheckRequest {
@@ -287,7 +292,10 @@ message DiffResponse {
287292
repeated string stables = 2; // an optional list of properties that will not ever change.
288293
bool deleteBeforeReplace = 3; // if true, this resource must be deleted before replacing it.
289294
DiffChanges changes = 4; // if true, this diff represents an actual difference and thus requires an update.
290-
repeated string diffs = 5; // a list of the properties that changed.
295+
296+
// a list of the properties that changed. This should only contain top level property names, it does not
297+
// support nested properties. For that use detailedDiff.
298+
repeated string diffs = 5;
291299

292300
// detailedDiff is an optional field that contains map from each changed property to the type of the change.
293301
//

pulumi-language-dotnet/go.mod

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ require (
1212
github.com/blang/semver v3.5.1+incompatible
1313
github.com/golang/protobuf v1.5.4
1414
github.com/pkg/errors v0.9.1
15-
github.com/pulumi/pulumi/sdk/v3 v3.121.0
15+
github.com/pulumi/pulumi/sdk/v3 v3.130.1-0.20240904113146-aea75ebb0c5c
1616
github.com/stretchr/testify v1.9.0
1717
google.golang.org/grpc v1.64.1
1818
)

pulumi-language-dotnet/go.sum

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,12 @@ github.com/pulumi/esc v0.9.1 h1:HH5eEv8sgyxSpY5a8yePyqFXzA8cvBvapfH8457+mIs=
153153
github.com/pulumi/esc v0.9.1/go.mod h1:oEJ6bOsjYlQUpjf70GiX+CXn3VBmpwFDxUTlmtUN84c=
154154
github.com/pulumi/pulumi/sdk/v3 v3.121.0 h1:UsnFKIVOtJN/hQKPkWHL9cZktewPVQRbNUXbXQY/qrk=
155155
github.com/pulumi/pulumi/sdk/v3 v3.121.0/go.mod h1:p1U24en3zt51agx+WlNboSOV8eLlPWYAkxMzVEXKbnY=
156+
github.com/pulumi/pulumi/sdk/v3 v3.130.0 h1:gGJNd+akPqhZ+vrsZmAjSNJn6kGJkitjjkwrmIQMmn8=
157+
github.com/pulumi/pulumi/sdk/v3 v3.130.0/go.mod h1:p1U24en3zt51agx+WlNboSOV8eLlPWYAkxMzVEXKbnY=
158+
github.com/pulumi/pulumi/sdk/v3 v3.130.1-0.20240830143150-217187d9a80e h1:Wasva8yrbehqVP5hRmbI3WdXhxSaL464tmDvPEi5px8=
159+
github.com/pulumi/pulumi/sdk/v3 v3.130.1-0.20240830143150-217187d9a80e/go.mod h1:p1U24en3zt51agx+WlNboSOV8eLlPWYAkxMzVEXKbnY=
160+
github.com/pulumi/pulumi/sdk/v3 v3.130.1-0.20240904113146-aea75ebb0c5c h1:gbqzigKBHfcqXH+asqJiCpXOGGXT7SI8uD9q+J+5wNE=
161+
github.com/pulumi/pulumi/sdk/v3 v3.130.1-0.20240904113146-aea75ebb0c5c/go.mod h1:J5kQEX8v87aeUhk6NdQXnjCo1DbiOnOiL3Sf2DuDda8=
156162
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
157163
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
158164
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=

pulumi-language-dotnet/main.go

Lines changed: 117 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import (
2020
"encoding/json"
2121
"flag"
2222
"fmt"
23+
"io"
2324
"io/ioutil"
2425
"math/rand"
2526
"os"
@@ -35,12 +36,15 @@ import (
3536
"github.com/pkg/errors"
3637
"github.com/pulumi/pulumi/sdk/v3/go/common/resource/plugin"
3738
"github.com/pulumi/pulumi/sdk/v3/go/common/util/cmdutil"
39+
"github.com/pulumi/pulumi/sdk/v3/go/common/util/contract"
3840
"github.com/pulumi/pulumi/sdk/v3/go/common/util/executable"
3941
"github.com/pulumi/pulumi/sdk/v3/go/common/util/logging"
4042
"github.com/pulumi/pulumi/sdk/v3/go/common/util/rpcutil"
4143
"github.com/pulumi/pulumi/sdk/v3/go/common/version"
4244
pulumirpc "github.com/pulumi/pulumi/sdk/v3/proto/go"
4345
"google.golang.org/grpc"
46+
"google.golang.org/grpc/credentials/insecure"
47+
"google.golang.org/protobuf/types/known/structpb"
4448
)
4549

4650
// A exit-code we recognize when the nodejs process exits. If we see this error, there's no
@@ -150,6 +154,22 @@ func newLanguageHost(exec, engineAddress, tracing string, binary string) pulumir
150154
}
151155
}
152156

157+
func (host *dotnetLanguageHost) connectToEngine() (pulumirpc.EngineClient, io.Closer, error) {
158+
// Make a connection to the real engine that we will log messages to.
159+
conn, err := grpc.Dial(
160+
host.engineAddress,
161+
grpc.WithTransportCredentials(insecure.NewCredentials()),
162+
rpcutil.GrpcChannelOptions(),
163+
)
164+
if err != nil {
165+
return nil, nil, fmt.Errorf("language host could not make connection to engine: %w", err)
166+
}
167+
168+
// Make a client around that connection.
169+
engineClient := pulumirpc.NewEngineClient(conn)
170+
return engineClient, conn, nil
171+
}
172+
153173
// GetRequiredPlugins computes the complete set of anticipated plugins required by a program.
154174
func (host *dotnetLanguageHost) GetRequiredPlugins(
155175
ctx context.Context,
@@ -162,19 +182,11 @@ func (host *dotnetLanguageHost) GetRequiredPlugins(
162182
return &pulumirpc.GetRequiredPluginsResponse{}, nil
163183
}
164184

165-
// Make a connection to the real engine that we will log messages to.
166-
conn, err := grpc.Dial(
167-
host.engineAddress,
168-
grpc.WithInsecure(),
169-
rpcutil.GrpcChannelOptions(),
170-
)
185+
engineClient, closer, err := host.connectToEngine()
171186
if err != nil {
172-
return nil, errors.Wrapf(err, "language host could not make connection to engine")
187+
return nil, err
173188
}
174-
175-
// Make a client around that connection. We can then make our own server that will act as a
176-
// monitor for the sdk and forward to the real monitor.
177-
engineClient := pulumirpc.NewEngineClient(conn)
189+
defer contract.IgnoreClose(closer)
178190

179191
// First do a `dotnet build`. This will ensure that all the nuget dependencies of the project
180192
// are restored and locally available for us.
@@ -547,8 +559,47 @@ func (w *logWriter) LogToUser(val string) (int, error) {
547559
return len(val), nil
548560
}
549561

562+
func (host *dotnetLanguageHost) buildDll() (string, error) {
563+
// If we are running from source, we need to build the project.
564+
// Run the `dotnet build` command. Importantly, report the output of this to the user
565+
// (ephemerally) as it is happening so they're aware of what's going on and can see the progress
566+
// of things.
567+
args := []string{"build", "-nologo", "-o", "bin/pulumi-debugging"}
568+
569+
cmd := exec.Command(host.exec, args...)
570+
err := cmd.Run()
571+
if err != nil {
572+
return "", errors.Wrapf(err, "failed to build project: %v", err)
573+
}
574+
575+
var binaryPath string
576+
err = filepath.WalkDir(".", func(path string, d os.DirEntry, err error) error {
577+
if err != nil {
578+
return err
579+
}
580+
581+
if name, ok := strings.CutSuffix(d.Name(), ".csproj"); ok {
582+
binaryPath = filepath.Join("bin", "pulumi-debugging", name+".dll")
583+
return filepath.SkipAll
584+
}
585+
return nil
586+
})
587+
588+
return binaryPath, err
589+
}
590+
550591
// Run is the RPC endpoint for LanguageRuntimeServer::Run
551592
func (host *dotnetLanguageHost) Run(ctx context.Context, req *pulumirpc.RunRequest) (*pulumirpc.RunResponse, error) {
593+
binaryPath := host.binary
594+
if req.GetAttachDebugger() && host.binary == "" {
595+
binaryPath, err := host.buildDll()
596+
if err != nil {
597+
return nil, err
598+
}
599+
if binaryPath == "" {
600+
return nil, errors.New("failed to find .csproj file, and could not start debugging")
601+
}
602+
}
552603
config, err := host.constructConfig(req)
553604
if err != nil {
554605
err = errors.Wrap(err, "failed to serialize configuration")
@@ -564,12 +615,12 @@ func (host *dotnetLanguageHost) Run(ctx context.Context, req *pulumirpc.RunReque
564615
args := []string{}
565616

566617
switch {
567-
case host.binary != "" && strings.HasSuffix(host.binary, ".dll"):
618+
case binaryPath != "" && strings.HasSuffix(binaryPath, ".dll"):
568619
// Portable pre-compiled dll: run `dotnet <name>.dll`
569-
args = append(args, host.binary)
570-
case host.binary != "":
620+
args = append(args, binaryPath)
621+
case binaryPath != "":
571622
// Self-contained executable: run it directly.
572-
executable = host.binary
623+
executable = binaryPath
573624
default:
574625
// Run from source.
575626
args = append(args, "run")
@@ -591,13 +642,36 @@ func (host *dotnetLanguageHost) Run(ctx context.Context, req *pulumirpc.RunReque
591642
logging.V(5).Infoln("Language host launching process: ", host.exec, commandStr)
592643
}
593644

645+
cmd := exec.Command(executable, args...) // nolint: gas // intentionally running dynamic program name.
646+
594647
// Now simply spawn a process to execute the requested program, wiring up stdout/stderr directly.
595648
var errResult string
596-
cmd := exec.Command(executable, args...) // nolint: gas // intentionally running dynamic program name.
597649
cmd.Stdout = os.Stdout
598650
cmd.Stderr = os.Stderr
599651
cmd.Env = host.constructEnv(req, config, configSecretKeys)
600-
if err := cmd.Run(); err != nil {
652+
if err := cmd.Start(); err != nil {
653+
return nil, err
654+
}
655+
656+
if req.GetAttachDebugger() {
657+
engineClient, closer, err := host.connectToEngine()
658+
if err != nil {
659+
return nil, err
660+
}
661+
defer contract.IgnoreClose(closer)
662+
663+
ctx, cancel := context.WithCancel(ctx)
664+
defer cancel()
665+
go func() {
666+
err = startDebugging(ctx, engineClient, cmd)
667+
if err != nil {
668+
// kill the program if we can't start debugging.
669+
logging.Errorf("Unable to start debugging: %v", err)
670+
contract.IgnoreError(cmd.Process.Kill())
671+
}
672+
}()
673+
}
674+
if err := cmd.Wait(); err != nil {
601675
if exiterr, ok := err.(*exec.ExitError); ok {
602676
// If the program ran, but exited with a non-zero error code. This will happen often, since user
603677
// errors will trigger this. So, the error message should look as nice as possible.
@@ -625,6 +699,31 @@ func (host *dotnetLanguageHost) Run(ctx context.Context, req *pulumirpc.RunReque
625699
return &pulumirpc.RunResponse{Error: errResult}, nil
626700
}
627701

702+
func startDebugging(ctx context.Context, engineClient pulumirpc.EngineClient, cmd *exec.Cmd) error {
703+
// wait for the debugger to be ready
704+
ctx, _ = context.WithTimeoutCause(ctx, 1*time.Minute, errors.New("debugger startup timed out"))
705+
// wait for the debugger to be ready
706+
707+
debugConfig, err := structpb.NewStruct(map[string]interface{}{
708+
"name": "Pulumi: Program (Dotnet)",
709+
"type": "coreclr",
710+
"request": "attach",
711+
"processId": cmd.Process.Pid,
712+
})
713+
if err != nil {
714+
return err
715+
}
716+
_, err = engineClient.StartDebugging(ctx, &pulumirpc.StartDebuggingRequest{
717+
Config: debugConfig,
718+
Message: fmt.Sprintf("on process id %d", cmd.Process.Pid),
719+
})
720+
if err != nil {
721+
return fmt.Errorf("unable to start debugging: %w", err)
722+
}
723+
724+
return nil
725+
}
726+
628727
func (host *dotnetLanguageHost) constructEnv(req *pulumirpc.RunRequest, config, configSecretKeys string) []string {
629728
env := os.Environ()
630729

@@ -646,6 +745,7 @@ func (host *dotnetLanguageHost) constructEnv(req *pulumirpc.RunRequest, config,
646745
maybeAppendEnv("tracing", host.tracing)
647746
maybeAppendEnv("config", config)
648747
maybeAppendEnv("config_secret_keys", configSecretKeys)
748+
maybeAppendEnv("attach_debugger", fmt.Sprint(req.GetAttachDebugger()))
649749

650750
return env
651751
}

0 commit comments

Comments
 (0)