Skip to content

Commit d2fbb62

Browse files
committed
debugAdapter: Fix bugs that cause remote debug to hang
The issues being fixed in this PR: 1. There are instances where we are issuing a blocking call to Delve while it is running. That blocks subsequent requests to Delve and can cause the debugger to appear as hanged. Hence, we will need to check Delve's debug state before issuing blocking request in `stacktraceRequest` and `threadsRequest` (fixes #740). 2. When disconnecting, multiple requests `disconnectRequest` can be received. Subsequent `disconnectRequest` should not invoke Delve through the JSON RPC connection again as this may already be closed so we need to guard against that (fixes #766). 3. Also, even if Delve is blocked in `disconnectRequest`, `disconnectResponse` should always be sent back to VSCode after a period of time. Otherwise, in the case of Theia in particular, users will not be able to reset (fixes #761). 4. In `close` function of `Delve` class, we are not resolving a promise in the case of remote debugging and this causes the `close` function to hang and not send the `disconnectResponse` back (fixes #764). 5. Add more logging to help us easily debug similar scenarios in the future (fixes #763). ## Repro 1. Clones a Go program (for example: https://github.com/GoogleCloudPlatform/cloud-code-samples/tree/master/golang/go-hello-world). 2. Runs the program in Delve through a terminal: `dlv debug --continue --accept-multiclient --api-version=2 --headless --listen=:3456 --log` 3. Connect to it by creating a configuration: ``` { "name": "Connect to server", "type": "go", "request": "attach", "mode": "remote", "remotePath": "${workspaceFolder}", "port": 3456, "host": "127.0.0.1", "cwd": "${workspaceFolder}" } ``` 4. Sets a breakpoint and sees that it's not working. 5. Disconnecting also does not work. Change-Id: Iaa385c392326f489a9ec0e804d2c2bce6cc3defe GitHub-Last-Rev: e052642 GitHub-Pull-Request: #686 Reviewed-on: https://go-review.googlesource.com/c/vscode-go/+/257337 Trust: Quoc Truong <quoct@google.com> Reviewed-by: Polina Sokolova <polina@google.com>
1 parent c17c69f commit d2fbb62

File tree

1 file changed

+109
-49
lines changed

1 file changed

+109
-49
lines changed

src/debugAdapter/goDebug.ts

Lines changed: 109 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -343,6 +343,16 @@ function findPathSeparator(filePath: string) {
343343
return filePath.includes('/') ? '/' : '\\';
344344
}
345345

346+
// Comparing two different file paths while ignoring any different path separators.
347+
function compareFilePathIgnoreSeparator(firstFilePath: string, secondFilePath: string): boolean {
348+
const firstSeparator = findPathSeparator(firstFilePath);
349+
const secondSeparator = findPathSeparator(secondFilePath);
350+
if (firstSeparator === secondSeparator) {
351+
return firstFilePath === secondFilePath;
352+
}
353+
return firstFilePath === secondFilePath.split(secondSeparator).join(firstSeparator);
354+
}
355+
346356
export function escapeGoModPath(filePath: string) {
347357
return filePath.replace(/[A-Z]/g, (match: string) => `!${match.toLocaleLowerCase()}`);
348358
}
@@ -376,6 +386,7 @@ export class Delve {
376386
public stackTraceDepth: number;
377387
public isRemoteDebugging: boolean;
378388
public goroot: string;
389+
public delveConnectionClosed = false;
379390
private localDebugeePath: string | undefined;
380391
private debugProcess: ChildProcess;
381392
private request: 'attach' | 'launch';
@@ -695,9 +706,10 @@ export class Delve {
695706
*
696707
* For attach debugging there are two scenarios; attaching to a local process by ID or connecting to a
697708
* remote delve server. For attach-local we start the delve process so will also terminate it however we
698-
* detach from the debugee without killing it. For attach-remote we only detach from delve.
709+
* detach from the debugee without killing it. For attach-remote we only close the client connection,
710+
* but do not terminate the remote server.
699711
*
700-
* The only way to detach from delve when it is running a program is to send a Halt request first.
712+
* For local debugging, the only way to detach from delve when it is running a program is to send a Halt request first.
701713
* Since the Halt request might sometimes take too long to complete, we have a timer in place to forcefully kill
702714
* the debug process and clean up the assets in case of local debugging
703715
*/
@@ -713,18 +725,19 @@ export class Delve {
713725
await forceCleanup();
714726
return Promise.resolve();
715727
}
716-
log('HaltRequest');
717728
const isLocalDebugging: boolean = this.request === 'launch' && !!this.debugProcess;
718729

719730
return new Promise(async (resolve) => {
720-
// For remote debugging, closing the connection would terminate the
721-
// program as well so we just want to disconnect.
731+
// For remote debugging, we want to leave the remote dlv server running,
732+
// so instead of killing it via halt+detach, we just close the network connection.
722733
// See https://www.github.com/go-delve/delve/issues/1587
723734
if (this.isRemoteDebugging) {
735+
log('Remote Debugging: close dlv connection.');
724736
const rpcConnection = await this.connection;
725737
// tslint:disable-next-line no-any
726738
(rpcConnection as any)['conn']['end']();
727-
return;
739+
this.delveConnectionClosed = true;
740+
return resolve();
728741
}
729742
const timeoutToken: NodeJS.Timer =
730743
isLocalDebugging &&
@@ -736,6 +749,7 @@ export class Delve {
736749

737750
let haltErrMsg: string;
738751
try {
752+
log('HaltRequest');
739753
await this.callPromise('Command', [{ name: 'halt' }]);
740754
} catch (err) {
741755
log('HaltResponse');
@@ -854,30 +868,44 @@ export class GoDebugSession extends LoggingDebugSession {
854868
args: DebugProtocol.DisconnectArguments
855869
): Promise<void> {
856870
log('DisconnectRequest');
857-
if (!this.delve) {
858-
log('DisconnectRequest to parent');
859-
super.disconnectRequest(response, args);
860-
log('DisconnectResponse');
861-
return;
871+
if (this.delve) {
872+
// Since users want to reset when they issue a disconnect request,
873+
// we should have a timeout in case disconnectRequestHelper hangs.
874+
await Promise.race([
875+
this.disconnectRequestHelper(response, args),
876+
new Promise((resolve) => setTimeout(() => {
877+
log('DisconnectRequestHelper timed out after 5s.');
878+
resolve();
879+
}, 5_000))
880+
]);
862881
}
863882

883+
this.shutdownProtocolServer(response, args);
884+
log('DisconnectResponse');
885+
}
886+
887+
protected async disconnectRequestHelper(
888+
response: DebugProtocol.DisconnectResponse,
889+
args: DebugProtocol.DisconnectArguments
890+
): Promise<void> {
864891
// For remote process, we have to issue a continue request
865892
// before disconnecting.
866893
if (this.delve.isRemoteDebugging) {
867-
// We don't have to wait for continue call
868-
// because we are not doing anything with the result.
869-
// Also, DisconnectRequest will return before
870-
// we get the result back from delve.
871-
this.debugState = await this.delve.getDebugState();
872-
if (!this.debugState.Running) {
894+
// There is a chance that a second disconnectRequest can come through
895+
// if users click detach multiple times. In that case, we want to
896+
// guard against talking to the closed Delve connection.
897+
if (this.delve.delveConnectionClosed) {
898+
log(`Skip disconnectRequestHelper as Delve's connection is already closed.`);
899+
return;
900+
}
901+
902+
if (!(await this.isDebuggeeRunning())) {
903+
log(`Issuing a continue command before closing Delve's connection as the debuggee is not running.`);
873904
this.continue();
874905
}
875906
}
876-
this.delve.close().then(() => {
877-
log('DisconnectRequest to parent');
878-
super.disconnectRequest(response, args);
879-
log('DisconnectResponse');
880-
});
907+
log('Closing Delve.');
908+
await this.delve.close();
881909
}
882910

883911
protected async configurationDoneRequest(
@@ -888,12 +916,9 @@ export class GoDebugSession extends LoggingDebugSession {
888916
if (this.stopOnEntry) {
889917
this.sendEvent(new StoppedEvent('entry', 1));
890918
log('StoppedEvent("entry")');
891-
} else {
892-
this.debugState = await this.delve.getDebugState();
893-
if (!this.debugState.Running) {
894-
log('Changing DebugState from Halted to Running');
895-
this.continue();
896-
}
919+
} else if (!await this.isDebuggeeRunning()) {
920+
log('Changing DebugState from Halted to Running');
921+
this.continue();
897922
}
898923
this.sendResponse(response);
899924
log('ConfigurationDoneResponse', response);
@@ -1168,19 +1193,12 @@ export class GoDebugSession extends LoggingDebugSession {
11681193
args: DebugProtocol.SetBreakpointsArguments
11691194
): Promise<void> {
11701195
log('SetBreakPointsRequest');
1171-
try {
1172-
// If a program is launched with --continue, the program is running
1173-
// before we can run attach. So we would need to check the state.
1174-
// We use NonBlocking so the call would return immediately.
1175-
this.debugState = await this.delve.getDebugState();
1176-
} catch (error) {
1177-
this.logDelveError(error, 'Failed to get state');
1178-
}
1179-
1180-
if (!this.debugState.Running && !this.continueRequestRunning) {
1196+
if (!(await this.isDebuggeeRunning())) {
1197+
log('Debuggee is not running. Setting breakpoints without halting.');
11811198
await this.setBreakPoints(response, args);
11821199
} else {
11831200
this.skipStopEventOnce = this.continueRequestRunning;
1201+
log(`Halting before setting breakpoints. SkipStopEventOnce is ${this.skipStopEventOnce}.`);
11841202
this.delve.callPromise('Command', [{ name: 'halt' }]).then(
11851203
() => {
11861204
return this.setBreakPoints(response, args).then(() => {
@@ -1203,9 +1221,9 @@ export class GoDebugSession extends LoggingDebugSession {
12031221
}
12041222
}
12051223

1206-
protected threadsRequest(response: DebugProtocol.ThreadsResponse): void {
1207-
if (this.continueRequestRunning) {
1208-
// Thread request to delve is syncronous and will block if a previous async continue request didn't return
1224+
protected async threadsRequest(response: DebugProtocol.ThreadsResponse): Promise<void> {
1225+
if ((await this.isDebuggeeRunning())) {
1226+
// Thread request to delve is synchronous and will block if a previous async continue request didn't return
12091227
response.body = { threads: [new Thread(1, 'Dummy')] };
12101228
return this.sendResponse(response);
12111229
}
@@ -1244,11 +1262,20 @@ export class GoDebugSession extends LoggingDebugSession {
12441262
});
12451263
}
12461264

1247-
protected stackTraceRequest(
1265+
protected async stackTraceRequest(
12481266
response: DebugProtocol.StackTraceResponse,
12491267
args: DebugProtocol.StackTraceArguments
1250-
): void {
1268+
): Promise<void> {
12511269
log('StackTraceRequest');
1270+
// For normal VSCode, this request doesn't get invoked when we send a Dummy thread
1271+
// in the scenario where the debuggee is running.
1272+
// For Theia, however, this does get invoked and so we should just send an error
1273+
// response that we cannot get the stack trace at this point since the debugggee is running.
1274+
if (await this.isDebuggeeRunning()) {
1275+
this.sendErrorResponse(response, 2004, 'Unable to produce stack trace as the debugger is running');
1276+
return;
1277+
}
1278+
12521279
// delve does not support frame paging, so we ask for a large depth
12531280
const goroutineId = args.threadId;
12541281
const stackTraceIn = { id: goroutineId, depth: this.delve.stackTraceDepth };
@@ -1919,11 +1946,14 @@ export class GoDebugSession extends LoggingDebugSession {
19191946
return null;
19201947
}
19211948
}
1949+
1950+
// Make sure that we compare the file names with the same separators.
19221951
const matchedBreakpoint = existingBreakpoints.find(
19231952
(existingBreakpoint) =>
1924-
existingBreakpoint.line === breakpointIn.line &&
1925-
existingBreakpoint.file === breakpointIn.file
1953+
existingBreakpoint.line === breakpointIn.line
1954+
&& compareFilePathIgnoreSeparator(existingBreakpoint.file, breakpointIn.file)
19261955
);
1956+
19271957
if (!matchedBreakpoint) {
19281958
log(`Cannot match breakpoint ${breakpointIn} with existing breakpoints.`);
19291959
return null;
@@ -2166,6 +2196,7 @@ export class GoDebugSession extends LoggingDebugSession {
21662196
}
21672197

21682198
private handleReenterDebug(reason: string): void {
2199+
log(`handleReenterDebug(${reason}).`);
21692200
this.cleanupHandles();
21702201

21712202
if (this.debugState.exited) {
@@ -2190,6 +2221,7 @@ export class GoDebugSession extends LoggingDebugSession {
21902221
}
21912222

21922223
if (this.skipStopEventOnce) {
2224+
log(`Skipping stop event for ${reason}. The current Go routines is ${this.debugState?.currentGoroutine}.`);
21932225
this.skipStopEventOnce = false;
21942226
return;
21952227
}
@@ -2201,6 +2233,34 @@ export class GoDebugSession extends LoggingDebugSession {
22012233
});
22022234
}
22032235
}
2236+
2237+
// Returns true if the debuggee is running.
2238+
// The call getDebugState is non-blocking so it should return
2239+
// almost instantaneously. However, if we run into some errors,
2240+
// we will fall back to the internal tracking of the debug state.
2241+
// TODO: If Delve is not in multi-client state, we can simply
2242+
// track the running state with continueRequestRunning internally
2243+
// instead of issuing a getDebugState call to Delve. Perhaps we want to
2244+
// do that to improve performance in the future.
2245+
private async isDebuggeeRunning(): Promise<boolean> {
2246+
try {
2247+
this.debugState = await this.delve.getDebugState();
2248+
return this.debugState.Running;
2249+
} catch (error) {
2250+
this.logDelveError(error, 'Failed to get state');
2251+
// Fall back to the internal tracking.
2252+
return this.continueRequestRunning;
2253+
}
2254+
}
2255+
2256+
private shutdownProtocolServer(
2257+
response: DebugProtocol.DisconnectResponse,
2258+
args: DebugProtocol.DisconnectArguments
2259+
): void {
2260+
log('DisconnectRequest to parent to shut down protocol server.');
2261+
super.disconnectRequest(response, args);
2262+
}
2263+
22042264
private continue(calledWhenSettingBreakpoint?: boolean): Thenable<void> {
22052265
this.continueEpoch++;
22062266
const closureEpoch = this.continueEpoch;
@@ -2364,13 +2424,13 @@ export class GoDebugSession extends LoggingDebugSession {
23642424
// Debugger may be stopped at this point but we still can (and need) to obtain state and stacktrace
23652425
let goroutineId = 0;
23662426
try {
2367-
const stateCallResult = await this.delve.getDebugState();
2427+
this.debugState = await this.delve.getDebugState();
23682428
// In some fault scenarios there may not be a currentGoroutine available from the debugger state
23692429
// Use the current thread
2370-
if (!stateCallResult.currentGoroutine) {
2371-
goroutineId = stateCallResult.currentThread.goroutineID;
2430+
if (!this.debugState.currentGoroutine) {
2431+
goroutineId = this.debugState.currentThread.goroutineID;
23722432
} else {
2373-
goroutineId = stateCallResult.currentGoroutine.id;
2433+
goroutineId = this.debugState.currentGoroutine.id;
23742434
}
23752435
} catch (error) {
23762436
logError('dumpStacktrace - Failed to get debugger state ' + error);

0 commit comments

Comments
 (0)