Skip to content

Commit

Permalink
src/goDebugFactory: support integrated/external console
Browse files Browse the repository at this point in the history
Implementation is done by taking advantage of VS Code's RunInTerminal
implementation.

Upon receiving a `initializeRequest` with console=integrated/external
request, the thin debug adapter (DelveDAPOutputAdapter) starts a
temporary TCP listener and sends a RunInTerminal request to start the
`dlv dap --client-addr` command from the specified terminal.
The `dlv dap` command will connect to the TCP listener, and then
DelveDAPOutputAdapter starts to forward messages between VS Code and
the dlv dap process running in the terminal.

Strictly speaking, sending a RunInTerminal request before `initializeResponse`
is incorrect. According to DAP spec:

```
Until the debug adapter has responded to with an ‘initialize’ response, the client must not send any additional requests or events to the debug adapter.

In addition the debug adapter is not allowed to send any requests or events to the client until it has responded with an ‘initialize’ response.
```

More correct ways are either

1) do not depend on RunInTerminal, but directly manage terminals using
vscode terminal APIs, or
  - we cannot utilize the RunInTerminal implementation vscode team
  actively manages and keeps improving.
  - there is no public vscode API for external console mode support
  so we need to implement it from scratch.

2) first respond with an `initialize` response specifying a minimal
capability before sending RunInTerminal request. Once `dlv dap` starts,
forward the stashed `initialize` request to `dlv dap`. When `dlv dap`
returns an `initialize` response, compute the capability difference
and report them as an Capabilities event.
  - add complexity to the DelveDAPOutputDebugAdapter, which
    will no longer be a thin adapter.
  - need significant changes in testing we built using DebugClient.
  - what capabilities changed through Capabilities events are honored
    is also unspeficied in the spec.

3) Delve DAP implements a proper Debug Adapter including reverse
requests. This is the most ideal solution that benefit other DAP-aware
editors.
  - need to agree with delve devs, so may need more time and discussion.

4) ask the vscode team if debug's RunInTerminal capability can be exposed
as a vscode API. If that's possible, we even can do it before the
initialize request and set up this from the factory or tracker.
  - new API. no guarantee if this can be accepted.

Will investigate the possibility of 4 or 1. If not working, we will
work harder to convince delve devs with option 3.

Updates #124

Change-Id: I3d28356a3da99832785815f8af43f7443acf8863
Reviewed-on: https://go-review.googlesource.com/c/vscode-go/+/358618
Trust: Hyang-Ah Hana Kim <hyangah@gmail.com>
Run-TryBot: Hyang-Ah Hana Kim <hyangah@gmail.com>
Reviewed-by: Suzy Mueller <suzmue@golang.org>
  • Loading branch information
hyangah committed Dec 29, 2021
1 parent f4e5154 commit 943ff11
Show file tree
Hide file tree
Showing 2 changed files with 130 additions and 89 deletions.
164 changes: 77 additions & 87 deletions src/goDebugFactory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -228,9 +228,32 @@ export class DelveDAPOutputAdapter extends ProxyDebugAdapter {
private socket: net.Socket;
private terminatedOnError = false;

protected sendMessageToClient(message: vscode.DebugProtocolMessage) {
const m = message as any;
if (m.type === 'request') {
logVerbose(`do not forward reverse request: dropping ${JSON.stringify(m)}`);
return;
}

super.sendMessageToClient(message);
}

protected async sendMessageToServer(message: vscode.DebugProtocolMessage): Promise<void> {
const m = message as any;
if (m.type === 'response') {
logVerbose(`do not forward reverse request response: dropping ${JSON.stringify(m)}`);
return;
}

if (!this.connected) {
this.connected = this.startAndConnectToServer();
if (m.type === 'request' && m.command === 'initialize') {
this.connected = this.launchDelveDAP();
} else {
this.connected = Promise.resolve({
connected: false,
reason: `the first message must be an initialize request, got ${JSON.stringify(m)}`
});
}
}
const { connected, reason } = await this.connected;
if (connected) {
Expand All @@ -243,7 +266,7 @@ export class DelveDAPOutputAdapter extends ProxyDebugAdapter {
this.outputEvent('stderr', errMsg);
this.sendMessageToClient(new TerminatedEvent());
}
if ((message as any).type === 'request') {
if (m.type === 'request') {
const req = message as DebugProtocol.Request;
this.sendMessageToClient({
seq: 0,
Expand Down Expand Up @@ -299,7 +322,7 @@ export class DelveDAPOutputAdapter extends ProxyDebugAdapter {
});
}

private async startAndConnectToServer() {
private async launchDelveDAP() {
try {
const { dlvDapServer, socket } = await this.startDapServer(this.configuration);

Expand All @@ -313,7 +336,7 @@ export class DelveDAPOutputAdapter extends ProxyDebugAdapter {
return { connected: true };
}

private outputEvent(dest: string, output: string, data?: any) {
protected outputEvent(dest: string, output: string, data?: any) {
this.sendMessageToClient(new OutputEvent(output, dest, data));
}

Expand All @@ -339,7 +362,7 @@ export class DelveDAPOutputAdapter extends ProxyDebugAdapter {
!dlvExternallyLaunched &&
(configuration.console === 'integrated' || configuration.console === 'external')
) {
return startDAPServerWithClientAddrFlag(configuration, log, logErr, logConsole);
return this.startDAPServerWithClientAddrFlag(configuration, logErr);
}
const host = configuration.host || '127.0.0.1';
const port = configuration.port || (await getPort());
Expand All @@ -363,104 +386,71 @@ export class DelveDAPOutputAdapter extends ProxyDebugAdapter {

return { dlvDapServer, socket };
}
}

async function startDAPServerWithClientAddrFlag(
launchAttachArgs: vscode.DebugConfiguration,
log: (msg: string) => void,
logErr: (msg: string) => void,
logConsole: (msg: string) => void
): Promise<{ dlvDapServer?: ChildProcessWithoutNullStreams; socket: net.Socket }> {
const { dlvArgs, dlvPath, dir, env } = getSpawnConfig(launchAttachArgs, logErr);
async startDAPServerWithClientAddrFlag(
launchAttachArgs: vscode.DebugConfiguration,
logErr: (msg: string) => void
): Promise<{ dlvDapServer?: ChildProcessWithoutNullStreams; socket: net.Socket }> {
// This is called only when launchAttachArgs.console === 'integrated' | 'external' currently.
const console = (launchAttachArgs as any).console || 'integrated';

// logDest - unlike startDAPServer that relies on piping log messages to a file descriptor
// using --log-dest, we can pass the user-specified logDest directly to the flag.
const logDest = launchAttachArgs.logDest;
if (logDest) {
dlvArgs.push(`--log-dest=${logDest}`);
}
const { dlvArgs, dlvPath, dir, env } = getSpawnConfig(launchAttachArgs, logErr);

const port = await getPort();
// TODO(hyangah): In module-module workspace mode, the program should be build in the super module directory
// where go.work (gopls.mod) file is present. Where dlv runs determines the build directory currently. Two options:
// 1) launch dlv in the super-module module directory and adjust launchArgs.cwd (--wd).
// 2) introduce a new buildDir launch attribute.
return new Promise<{ dlvDapServer: ChildProcessWithoutNullStreams; socket: net.Socket }>((resolve, reject) => {
let s: net.Server = undefined; // temporary server that waits for p to connect to.
let p: ChildProcessWithoutNullStreams = undefined; // dlv dap
// logDest - unlike startDAPServer that relies on piping log messages to a file descriptor
// using --log-dest, we can pass the user-specified logDest directly to the flag.
const logDest = launchAttachArgs.logDest;
if (logDest) {
dlvArgs.push(`--log-dest=${logDest}`);
}

const timeoutToken: NodeJS.Timer = setTimeout(() => {
try {
const port = await getPort();
const rendezvousServerPromise = waitForDAPServer(port, 30_000);

dlvArgs.push(`--client-addr=:${port}`);

dlvArgs.unshift(dlvPath);
super.sendMessageToClient({
seq: 0,
type: 'request',
command: 'runInTerminal',
arguments: {
kind: console,
title: `Go Debug Terminal (${launchAttachArgs.name})`,
cwd: dir,
args: dlvArgs,
env: env
}
});
const socket = await rendezvousServerPromise;
return { socket };
} catch (err) {
logErr(`Failed to launch dlv: ${err}`);
throw new Error('cannot launch dlv dap. See DEBUG CONSOLE');
}
}
}

function waitForDAPServer(port: number, timeoutMs: number): Promise<net.Socket> {
return new Promise((resolve, reject) => {
let s: net.Server = undefined;
const timeoutToken = setTimeout(() => {
if (s?.listening) {
s.close();
}
if (p) {
p.kill('SIGINT');
}
reject(new Error('timed out while waiting for DAP server to start'));
}, 30_000);
reject(new Error('timed out while waiting for DAP in reverse mode to connect'));
}, timeoutMs);

s = net.createServer({ pauseOnConnect: true }, (socket) => {
if (!p) {
logVerbose('unexpected connection, ignoring...');
socket.resume();
socket.destroy(new Error('unexpected connection'));
return;
}
logVerbose(`connected: ${port}`);
clearTimeout(timeoutToken);
s.close(); // accept no more connection
socket.resume();
resolve({ dlvDapServer: p, socket });
});
s.on('close', () => {
logVerbose('server is closed');
resolve(socket);
});
s.on('error', (err) => {
logVerbose(`server got error ${err}`);
reject(err);
});

s.on('error', (err) => reject(err));
s.maxConnections = 1;

s.listen(port);

dlvArgs.push(`--client-addr=:${port}`);

logConsole(`Starting: ${dlvPath} ${dlvArgs.join(' ')} from ${dir}\n`);

p = spawn(dlvPath, dlvArgs, {
cwd: dir,
env
});
p.stdout.on('data', (chunk) => {
const msg = chunk.toString();
log(msg);
});
p.stderr.on('data', (chunk) => {
logErr(chunk.toString());
});
p.on('close', (code, signal) => {
// TODO: should we watch 'exit' instead?

// NOTE: log messages here may not appear in DEBUG CONSOLE if the termination of
// the process was triggered by debug adapter's dispose when dlv dap doesn't
// respond to disconnect on time. In that case, it's possible that the session
// is in the middle of teardown and DEBUG CONSOLE isn't accessible. Check
// Go Debug output channel.
if (typeof code === 'number') {
// The process exited on its own.
logConsole(`dlv dap (${p.pid}) exited with code: ${code}\n`);
} else if (code === null && signal) {
logConsole(`dlv dap (${p.pid}) was killed by signal: ${signal}\n`);
} else {
logConsole(`dlv dap (${p.pid}) terminated with code: ${code} signal: ${signal}\n`);
}
});
p.on('error', (err) => {
if (err) {
logConsole(`Error: ${err}\n`);
}
});
});
}

Expand Down
55 changes: 53 additions & 2 deletions test/integration/goDebug.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2274,12 +2274,63 @@ class DelveDAPDebugAdapterOnSocket extends proxy.DelveDAPOutputAdapter {

// forward to DelveDAPDebugAdapter, which will forward to dlv dap.
inStream.on('data', (data: Buffer) => this._handleData(data));
// handle data from DelveDAPDebugAdapter, that's from dlv dap.
this.onDidSendMessage((m) => this._send(m));
// DebugClient silently drops reverse requests. Handle runInTerminal request here.
this.onDidSendMessage((m) => {
if (this.handleRunInTerminal(m)) {
return;
}
this._send(m);
});

inStream.resume();
}

// handleRunInTerminal spawns the requested command and simulates RunInTerminal
// handler implementation inside an editor.
private _dlvInTerminal: cp.ChildProcess;
private handleRunInTerminal(m: vscode.DebugProtocolMessage) {
const m0 = m as any;
if (m0['type'] !== 'request' || m0['command'] !== 'runInTerminal') {
return false;
}
const json = JSON.stringify(m0);
this.log(`<- server: ${json}`);

const resp = {
seq: 0,
type: 'response',
success: false,
request_seq: m0['seq'],
command: m0['command'],
body: {}
};

if (!this._dlvInTerminal && m0['arguments']?.args?.length > 0) {
const args = m0['arguments'].args as string[];
const p = cp.spawn(args[0], args.slice(1), {
cwd: m0['arguments'].cwd,
env: m0['arguments'].env
});
// stdout/stderr are supposed to appear in the terminal, but
// some of noDebug tests depend on access to stdout/stderr.
// For those tests, let's pump the output as OutputEvent.
p.stdout.on('data', (chunk) => {
this.outputEvent('stdout', chunk.toString());
});
p.stderr.on('data', (chunk) => {
this.outputEvent('stderr', chunk.toString());
});
resp.success = true;
resp.body = { processId: p.pid };
this._dlvInTerminal = p;
}

this.log(`-> server: ${JSON.stringify(resp)}`);
this.handleMessage(resp);

return true;
}

private _disposed = false;
public async dispose(timeoutMS?: number) {
if (this._disposed) {
Expand Down

0 comments on commit 943ff11

Please sign in to comment.