diff options
author | 2018-06-07 14:07:17 -0700 | |
---|---|---|
committer | 2018-06-07 14:08:44 -0700 | |
commit | 5e893626640351de0f12e36bb14d80af0ff1e036 (patch) | |
tree | b9a7083618290b867bd40383881a06e89060ce27 /src | |
parent | 755278df00f65818dc092fe4f8a31bdec1aaaab5 (diff) |
Add an initial skylark debug server implementation.
Not intending this to be a complete implementation yet.
Among the things still to do:
- officially add support for debugging aspects, rules, etc.,
with corresponding tests.
- handle breakpoints at return statements (an edge case which
bypasses Eval).
PiperOrigin-RevId: 199692670
Diffstat (limited to 'src')
15 files changed, 1895 insertions, 16 deletions
diff --git a/src/main/java/com/google/devtools/build/lib/skylarkdebug/module/BUILD b/src/main/java/com/google/devtools/build/lib/skylarkdebug/module/BUILD index 00fae167a0..0fc7f18c04 100644 --- a/src/main/java/com/google/devtools/build/lib/skylarkdebug/module/BUILD +++ b/src/main/java/com/google/devtools/build/lib/skylarkdebug/module/BUILD @@ -13,6 +13,8 @@ java_library( ":options", "//src/main/java/com/google/devtools/build/lib:events", "//src/main/java/com/google/devtools/build/lib:runtime", + "//src/main/java/com/google/devtools/build/lib:syntax", + "//src/main/java/com/google/devtools/build/lib/skylarkdebug/server", ], ) diff --git a/src/main/java/com/google/devtools/build/lib/skylarkdebug/module/SkylarkDebuggerModule.java b/src/main/java/com/google/devtools/build/lib/skylarkdebug/module/SkylarkDebuggerModule.java index 167d3c853b..8dbe9e020a 100644 --- a/src/main/java/com/google/devtools/build/lib/skylarkdebug/module/SkylarkDebuggerModule.java +++ b/src/main/java/com/google/devtools/build/lib/skylarkdebug/module/SkylarkDebuggerModule.java @@ -14,9 +14,13 @@ package com.google.devtools.build.lib.skylarkdebug.module; +import com.google.devtools.build.lib.events.Event; import com.google.devtools.build.lib.events.Reporter; import com.google.devtools.build.lib.runtime.BlazeModule; import com.google.devtools.build.lib.runtime.CommandEnvironment; +import com.google.devtools.build.lib.skylarkdebug.server.SkylarkDebugServer; +import com.google.devtools.build.lib.syntax.DebugServerUtils; +import java.io.IOException; /** Blaze module for setting up Skylark debugging. */ public final class SkylarkDebuggerModule extends BlazeModule { @@ -38,10 +42,16 @@ public final class SkylarkDebuggerModule extends BlazeModule { } private static void initializeDebugging(Reporter reporter, int debugPort) { - // TODO(brendandouglas): implement a debug server + try { + SkylarkDebugServer server = + SkylarkDebugServer.createAndWaitForConnection(reporter, debugPort); + DebugServerUtils.initializeDebugServer(server); + } catch (IOException e) { + reporter.handle(Event.error("Error while setting up the debug server: " + e.getMessage())); + } } private static void disableDebugging() { - // TODO(brendandouglas): implement a debug server + DebugServerUtils.disableDebugging(); } } diff --git a/src/main/java/com/google/devtools/build/lib/skylarkdebug/proto/skylark_debugging.proto b/src/main/java/com/google/devtools/build/lib/skylarkdebug/proto/skylark_debugging.proto index 804319397a..ed398e70fa 100644 --- a/src/main/java/com/google/devtools/build/lib/skylarkdebug/proto/skylark_debugging.proto +++ b/src/main/java/com/google/devtools/build/lib/skylarkdebug/proto/skylark_debugging.proto @@ -89,8 +89,9 @@ message StartDebuggingRequest { message PauseThreadRequest { // The identifier of the thread to be paused. // - // If not set (i.e. zero), all current and future Skylark threads will be - // paused until resumed via a ContinueExecutionRequest. + // If not set (i.e. zero), all current Skylark threads will be paused, and + // until a ContinueExecutionRequest is sent, any future Skylark threads will + // also start off paused. int64 thread_id = 1; } @@ -195,6 +196,7 @@ message ThreadContinuedEvent { message Breakpoint { oneof condition { // A breakpoint that is triggered when a particular line is reached. + // Column index will be ignored for breakpoints. Location location = 1; } // An optional condition for the breakpoint. When present, the breakpoint will @@ -220,11 +222,12 @@ message Location { // The path of the Skylark source file. string path = 1; - // A 0-based line index in the file denoted by path. - uint32 line_index = 2; + // A 1-indexed line number in the file denoted by path. + uint32 line_number = 2; - // A 0-based column index in the file denoted by path. - uint32 column_index = 3; + // A 1-indexed column number in the file denoted by path. 0 (/unset) indicates + // column number is unknown or irrelevant. + uint32 column_number = 3; } // A scope that contains value bindings accessible in a frame. diff --git a/src/main/java/com/google/devtools/build/lib/skylarkdebug/server/BUILD b/src/main/java/com/google/devtools/build/lib/skylarkdebug/server/BUILD index 42768bb69d..5122e25329 100644 --- a/src/main/java/com/google/devtools/build/lib/skylarkdebug/server/BUILD +++ b/src/main/java/com/google/devtools/build/lib/skylarkdebug/server/BUILD @@ -10,6 +10,7 @@ java_library( name = "server", srcs = glob(["*.java"]), deps = [ + "//src/main/java/com/google/devtools/build/lib:events", "//src/main/java/com/google/devtools/build/lib:skylarkinterface", "//src/main/java/com/google/devtools/build/lib:syntax", "//src/main/java/com/google/devtools/build/lib/collect/nestedset", diff --git a/src/main/java/com/google/devtools/build/lib/skylarkdebug/server/DebugEventHelper.java b/src/main/java/com/google/devtools/build/lib/skylarkdebug/server/DebugEventHelper.java new file mode 100644 index 0000000000..0254484062 --- /dev/null +++ b/src/main/java/com/google/devtools/build/lib/skylarkdebug/server/DebugEventHelper.java @@ -0,0 +1,203 @@ +// Copyright 2018 The Bazel Authors. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package com.google.devtools.build.lib.skylarkdebug.server; + +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableMap; +import com.google.devtools.build.lib.events.Location; +import com.google.devtools.build.lib.skylarkdebugging.SkylarkDebuggingProtos; +import com.google.devtools.build.lib.skylarkdebugging.SkylarkDebuggingProtos.ContinueExecutionResponse; +import com.google.devtools.build.lib.skylarkdebugging.SkylarkDebuggingProtos.DebugEvent; +import com.google.devtools.build.lib.skylarkdebugging.SkylarkDebuggingProtos.Error; +import com.google.devtools.build.lib.skylarkdebugging.SkylarkDebuggingProtos.EvaluateResponse; +import com.google.devtools.build.lib.skylarkdebugging.SkylarkDebuggingProtos.Frame; +import com.google.devtools.build.lib.skylarkdebugging.SkylarkDebuggingProtos.ListFramesResponse; +import com.google.devtools.build.lib.skylarkdebugging.SkylarkDebuggingProtos.ListThreadsResponse; +import com.google.devtools.build.lib.skylarkdebugging.SkylarkDebuggingProtos.PauseThreadResponse; +import com.google.devtools.build.lib.skylarkdebugging.SkylarkDebuggingProtos.Scope; +import com.google.devtools.build.lib.skylarkdebugging.SkylarkDebuggingProtos.SetBreakpointsResponse; +import com.google.devtools.build.lib.skylarkdebugging.SkylarkDebuggingProtos.StartDebuggingResponse; +import com.google.devtools.build.lib.skylarkdebugging.SkylarkDebuggingProtos.Thread; +import com.google.devtools.build.lib.skylarkdebugging.SkylarkDebuggingProtos.ThreadContinuedEvent; +import com.google.devtools.build.lib.skylarkdebugging.SkylarkDebuggingProtos.ThreadEndedEvent; +import com.google.devtools.build.lib.skylarkdebugging.SkylarkDebuggingProtos.ThreadPausedEvent; +import com.google.devtools.build.lib.skylarkdebugging.SkylarkDebuggingProtos.ThreadStartedEvent; +import com.google.devtools.build.lib.skylarkdebugging.SkylarkDebuggingProtos.Value; +import com.google.devtools.build.lib.syntax.DebugFrame; +import com.google.devtools.build.lib.syntax.Debuggable.Stepping; +import java.util.Collection; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import javax.annotation.Nullable; + +/** + * Helper class for constructing event or response protos to be sent from the debug server to a + * debugger client. + */ +final class DebugEventHelper { + private DebugEventHelper() {} + + private static final long NO_SEQUENCE_NUMBER = 0; + + static DebugEvent error(String message) { + return error(NO_SEQUENCE_NUMBER, message); + } + + static DebugEvent error(long sequenceNumber, String message) { + return DebugEvent.newBuilder() + .setSequenceNumber(sequenceNumber) + .setError(Error.newBuilder().setMessage(message)) + .build(); + } + + static DebugEvent listThreadsResponse(long sequenceNumber, List<Thread> threads) { + return DebugEvent.newBuilder() + .setSequenceNumber(sequenceNumber) + .setListThreads(ListThreadsResponse.newBuilder().addAllThread(threads).build()) + .build(); + } + + static DebugEvent setBreakpointsResponse(long sequenceNumber) { + return DebugEvent.newBuilder() + .setSequenceNumber(sequenceNumber) + .setSetBreakpoints(SetBreakpointsResponse.newBuilder()) + .build(); + } + + static DebugEvent continueExecutionResponse(long sequenceNumber) { + return DebugEvent.newBuilder() + .setSequenceNumber(sequenceNumber) + .setContinueExecution(ContinueExecutionResponse.newBuilder()) + .build(); + } + + static DebugEvent evaluateResponse(long sequenceNumber, Value value) { + return DebugEvent.newBuilder() + .setSequenceNumber(sequenceNumber) + .setEvaluate(EvaluateResponse.newBuilder().setResult(value)) + .build(); + } + + static DebugEvent listFramesResponse(long sequenceNumber, Collection<Frame> frames) { + return DebugEvent.newBuilder() + .setSequenceNumber(sequenceNumber) + .setListFrames(ListFramesResponse.newBuilder().addAllFrame(frames)) + .build(); + } + + static DebugEvent startDebuggingResponse(long sequenceNumber) { + return DebugEvent.newBuilder() + .setSequenceNumber(sequenceNumber) + .setStartDebugging(StartDebuggingResponse.newBuilder()) + .build(); + } + + static DebugEvent pauseThreadResponse(long sequenceNumber) { + return DebugEvent.newBuilder() + .setSequenceNumber(sequenceNumber) + .setPauseThread(PauseThreadResponse.newBuilder()) + .build(); + } + + static DebugEvent threadStartedEvent(long threadId, String threadName) { + return DebugEvent.newBuilder() + .setThreadStarted( + ThreadStartedEvent.newBuilder() + .setThread(Thread.newBuilder().setId(threadId).setName(threadName)) + .build()) + .build(); + } + + static DebugEvent threadEndedEvent(long threadId, String threadName) { + return DebugEvent.newBuilder() + .setThreadEnded( + ThreadEndedEvent.newBuilder() + .setThread(Thread.newBuilder().setId(threadId).setName(threadName)) + .build()) + .build(); + } + + static DebugEvent threadPausedEvent(Thread thread) { + return DebugEvent.newBuilder() + .setThreadPaused(ThreadPausedEvent.newBuilder().setThread(thread)) + .build(); + } + + static DebugEvent threadContinuedEvent(Thread thread) { + return DebugEvent.newBuilder() + .setThreadContinued(ThreadContinuedEvent.newBuilder().setThread(thread)) + .build(); + } + + @Nullable + static SkylarkDebuggingProtos.Location getLocationProto(@Nullable Location location) { + if (location == null) { + return null; + } + Location.LineAndColumn lineAndColumn = location.getStartLineAndColumn(); + if (lineAndColumn == null) { + return null; + } + return SkylarkDebuggingProtos.Location.newBuilder() + .setLineNumber(lineAndColumn.getLine()) + .setColumnNumber(lineAndColumn.getColumn()) + .setPath(location.getPath().getPathString()) + .build(); + } + + static SkylarkDebuggingProtos.Frame getFrameProto(DebugFrame frame) { + return SkylarkDebuggingProtos.Frame.newBuilder() + .setFunctionName(frame.functionName()) + .setLocation(getLocationProto(frame.location())) + .addAllScope(getScopes(frame)) + .build(); + } + + private static ImmutableList<Scope> getScopes(DebugFrame frame) { + ImmutableMap<String, Object> localVars = frame.lexicalFrameBindings(); + if (localVars.isEmpty()) { + return ImmutableList.of(getScope("global", frame.globalBindings())); + } + Map<String, Object> globalVars = new LinkedHashMap<>(frame.globalBindings()); + // remove shadowed bindings + localVars.keySet().forEach(globalVars::remove); + + return ImmutableList.of(getScope("local", localVars), getScope("global", globalVars)); + } + + private static SkylarkDebuggingProtos.Scope getScope(String name, Map<String, Object> bindings) { + SkylarkDebuggingProtos.Scope.Builder builder = + SkylarkDebuggingProtos.Scope.newBuilder().setName(name); + bindings.forEach((s, o) -> builder.addBinding(DebuggerSerialization.getValueProto(s, o))); + return builder.build(); + } + + static Stepping convertSteppingEnum(SkylarkDebuggingProtos.Stepping stepping) { + switch (stepping) { + case INTO: + return Stepping.INTO; + case OUT: + return Stepping.OUT; + case OVER: + return Stepping.OVER; + case NONE: + return Stepping.NONE; + case UNRECOGNIZED: + // fall through to exception + } + throw new IllegalArgumentException("Unsupported stepping type"); + } +} diff --git a/src/main/java/com/google/devtools/build/lib/skylarkdebug/server/DebugRequestException.java b/src/main/java/com/google/devtools/build/lib/skylarkdebug/server/DebugRequestException.java new file mode 100644 index 0000000000..092ac14fe5 --- /dev/null +++ b/src/main/java/com/google/devtools/build/lib/skylarkdebug/server/DebugRequestException.java @@ -0,0 +1,26 @@ +// Copyright 2018 The Bazel Authors. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package com.google.devtools.build.lib.skylarkdebug.server; + +/** + * An exception that occurs while performing a debug request. Should be caught and communicated back + * to the debug client. + */ +final class DebugRequestException extends Exception { + + DebugRequestException(String message) { + super(message); + } +} diff --git a/src/main/java/com/google/devtools/build/lib/skylarkdebug/server/DebugServerTransport.java b/src/main/java/com/google/devtools/build/lib/skylarkdebug/server/DebugServerTransport.java new file mode 100644 index 0000000000..0763fd5047 --- /dev/null +++ b/src/main/java/com/google/devtools/build/lib/skylarkdebug/server/DebugServerTransport.java @@ -0,0 +1,106 @@ +// Copyright 2018 The Bazel Authors. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package com.google.devtools.build.lib.skylarkdebug.server; + +import com.google.devtools.build.lib.events.Event; +import com.google.devtools.build.lib.events.EventHandler; +import com.google.devtools.build.lib.skylarkdebugging.SkylarkDebuggingProtos.DebugEvent; +import com.google.devtools.build.lib.skylarkdebugging.SkylarkDebuggingProtos.DebugRequest; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.net.ServerSocket; +import java.net.Socket; +import javax.annotation.Nullable; + +/** + * Manages the connection to and communication to/from the debugger client. Reading and writing are + * internally synchronized by {@link DebugServerTransport}. + */ +final class DebugServerTransport { + + /** Sets up the server transport and blocks while waiting for an incoming connection. */ + static DebugServerTransport createAndWaitForClient( + EventHandler eventHandler, ServerSocket serverSocket) throws IOException { + // TODO(bazel-team): reject all connections after the first + eventHandler.handle(Event.progress("Waiting for debugger...")); + Socket clientSocket = serverSocket.accept(); + return new DebugServerTransport( + eventHandler, + serverSocket, + clientSocket, + clientSocket.getInputStream(), + clientSocket.getOutputStream()); + } + + private final EventHandler eventHandler; + private final ServerSocket serverSocket; + private final Socket clientSocket; + private final InputStream requestStream; + private final OutputStream eventStream; + + private DebugServerTransport( + EventHandler eventHandler, + ServerSocket serverSocket, + Socket clientSocket, + InputStream requestStream, + OutputStream eventStream) { + this.eventHandler = eventHandler; + this.serverSocket = serverSocket; + this.clientSocket = clientSocket; + this.requestStream = requestStream; + this.eventStream = eventStream; + } + + /** + * Blocks waiting for a properly-formed client request. Returns null if the client connection is + * closed. + */ + @Nullable + DebugRequest readClientRequest() { + synchronized (requestStream) { + while (true) { + if (!clientSocket.isConnected() || clientSocket.isClosed()) { + return null; + } + try { + return DebugRequest.parseDelimitedFrom(requestStream); + } catch (IOException e) { + postEvent(DebugEventHelper.error("Error parsing debug request: " + e.getMessage())); + } + } + } + } + + /** Posts a debug event. */ + void postEvent(DebugEvent event) { + synchronized (eventStream) { + try { + event.writeDelimitedTo(eventStream); + } catch (IOException e) { + eventHandler.handle(Event.error("Failed to send debug event to client: " + e.getMessage())); + } + } + } + + void close() throws IOException { + clientSocket.close(); + serverSocket.close(); + } + + boolean isClosed() { + return serverSocket.isClosed() || clientSocket.isClosed(); + } +} diff --git a/src/main/java/com/google/devtools/build/lib/skylarkdebug/server/SkylarkDebugServer.java b/src/main/java/com/google/devtools/build/lib/skylarkdebug/server/SkylarkDebugServer.java new file mode 100644 index 0000000000..71a3d7b41f --- /dev/null +++ b/src/main/java/com/google/devtools/build/lib/skylarkdebug/server/SkylarkDebugServer.java @@ -0,0 +1,261 @@ +// Copyright 2018 The Bazel Authors. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package com.google.devtools.build.lib.skylarkdebug.server; + +import static com.google.common.collect.ImmutableSet.toImmutableSet; + +import com.google.common.annotations.VisibleForTesting; +import com.google.common.base.Throwables; +import com.google.devtools.build.lib.events.Event; +import com.google.devtools.build.lib.events.EventHandler; +import com.google.devtools.build.lib.events.Location; +import com.google.devtools.build.lib.skylarkdebugging.SkylarkDebuggingProtos; +import com.google.devtools.build.lib.skylarkdebugging.SkylarkDebuggingProtos.Breakpoint.ConditionCase; +import com.google.devtools.build.lib.syntax.DebugServer; +import com.google.devtools.build.lib.syntax.Environment; +import com.google.devtools.build.lib.syntax.Eval; +import com.google.devtools.build.lib.syntax.EvalException; +import com.google.devtools.build.lib.syntax.Statement; +import java.io.IOException; +import java.net.ServerSocket; +import java.util.List; +import java.util.function.Function; +import javax.annotation.Nullable; + +/** Manages the network socket and debugging state for threads running Skylark code. */ +public final class SkylarkDebugServer implements DebugServer { + + /** + * Initializes debugging support, setting up any debugging-specific overrides, then opens the + * debug server socket and blocks waiting for an incoming connection. + * + * @param port the port on which the server should listen for connections + * @throws IOException if an I/O error occurs while opening the socket or waiting for a connection + */ + public static SkylarkDebugServer createAndWaitForConnection(EventHandler eventHandler, int port) + throws IOException { + ServerSocket serverSocket = new ServerSocket(port, /* backlog */ 1); + return createAndWaitForConnection(eventHandler, serverSocket); + } + + /** + * Initializes debugging support, setting up any debugging-specific overrides, then opens the + * debug server socket and blocks waiting for an incoming connection. + * + * @throws IOException if an I/O error occurs while waiting for a connection + */ + @VisibleForTesting + static SkylarkDebugServer createAndWaitForConnection( + EventHandler eventHandler, ServerSocket serverSocket) throws IOException { + DebugServerTransport transport = + DebugServerTransport.createAndWaitForClient(eventHandler, serverSocket); + return new SkylarkDebugServer(eventHandler, transport); + } + + private final EventHandler eventHandler; + /** Handles all thread-related state. */ + private final ThreadHandler threadHandler; + /** The server socket for the debug server. */ + private final DebugServerTransport transport; + + private SkylarkDebugServer(EventHandler eventHandler, DebugServerTransport transport) { + this.eventHandler = eventHandler; + this.threadHandler = new ThreadHandler(); + this.transport = transport; + listenForClientRequests(); + } + + /** + * Starts a worker thread to listen for and handle incoming client requests, returning any + * relevant responses. + */ + private void listenForClientRequests() { + Thread clientThread = + new Thread( + () -> { + try { + while (true) { + SkylarkDebuggingProtos.DebugRequest request = transport.readClientRequest(); + if (request == null) { + return; + } + SkylarkDebuggingProtos.DebugEvent response = handleClientRequest(request); + if (response != null) { + transport.postEvent(response); + } + } + } catch (Throwable e) { + if (!transport.isClosed()) { + eventHandler.handle( + Event.error( + "Debug server listener thread died: " + + Throwables.getStackTraceAsString(e))); + } + } + }); + + clientThread.setDaemon(true); + clientThread.start(); + } + + @Override + public void close() { + try { + transport.close(); + + } catch (IOException e) { + eventHandler.handle( + Event.error( + "Error shutting down the debug server: " + Throwables.getStackTraceAsString(e))); + } + } + + @Override + public Function<Environment, Eval> evalOverride() { + return DebugAwareEval::new; + } + + @Override + public <T> T runWithDebugging(Environment env, String threadName, DebugCallable<T> callable) + throws EvalException, InterruptedException { + long threadId = Thread.currentThread().getId(); + threadHandler.registerThread(threadId, threadName, env); + transport.postEvent(DebugEventHelper.threadStartedEvent(threadId, threadName)); + try { + return callable.call(); + } finally { + transport.postEvent(DebugEventHelper.threadEndedEvent(threadId, threadName)); + threadHandler.unregisterThread(threadId); + } + } + + /** + * Pauses the execution of the current thread if there are conditions that should cause it to be + * paused, such as a breakpoint being reached. + * + * @param location the location of the statement or expression currently being executed + */ + @VisibleForTesting + void pauseIfNecessary(Environment env, Location location) { + if (!transport.isClosed()) { + threadHandler.pauseIfNecessary(env, location, transport); + } + } + + /** Handles a request from the client, and returns the response, where relevant. */ + @Nullable + private SkylarkDebuggingProtos.DebugEvent handleClientRequest( + SkylarkDebuggingProtos.DebugRequest request) { + long sequenceNumber = request.getSequenceNumber(); + try { + switch (request.getPayloadCase()) { + case START_DEBUGGING: + threadHandler.resumeAllThreads(); + return DebugEventHelper.startDebuggingResponse(sequenceNumber); + case LIST_THREADS: + return listThreads(sequenceNumber); + case LIST_FRAMES: + return listFrames(sequenceNumber, request.getListFrames()); + case SET_BREAKPOINTS: + return setBreakpoints(sequenceNumber, request.getSetBreakpoints()); + case CONTINUE_EXECUTION: + return continueExecution(sequenceNumber, request.getContinueExecution()); + case PAUSE_THREAD: + return pauseThread(sequenceNumber, request.getPauseThread()); + case EVALUATE: + return evaluate(sequenceNumber, request.getEvaluate()); + case PAYLOAD_NOT_SET: + DebugEventHelper.error(sequenceNumber, "No request payload found"); + } + return DebugEventHelper.error( + sequenceNumber, "Unhandled request type: " + request.getPayloadCase()); + } catch (DebugRequestException e) { + return DebugEventHelper.error(sequenceNumber, e.getMessage()); + } + } + + /** Handles a {@code ListThreadsRequest} and returns its response. */ + private SkylarkDebuggingProtos.DebugEvent listThreads(long sequenceNumber) { + return DebugEventHelper.listThreadsResponse(sequenceNumber, threadHandler.listThreads()); + } + + /** Handles a {@code ListFramesRequest} and returns its response. */ + private SkylarkDebuggingProtos.DebugEvent listFrames( + long sequenceNumber, SkylarkDebuggingProtos.ListFramesRequest request) + throws DebugRequestException { + List<SkylarkDebuggingProtos.Frame> frames = threadHandler.listFrames(request.getThreadId()); + return DebugEventHelper.listFramesResponse(sequenceNumber, frames); + } + + /** Handles a {@code SetBreakpointsRequest} and returns its response. */ + private SkylarkDebuggingProtos.DebugEvent setBreakpoints( + long sequenceNumber, SkylarkDebuggingProtos.SetBreakpointsRequest request) { + threadHandler.setBreakpoints( + request + .getBreakpointList() + .stream() + .filter(b -> b.getConditionCase() == ConditionCase.LOCATION) + .map(SkylarkDebuggingProtos.Breakpoint::getLocation) + .collect(toImmutableSet())); + return DebugEventHelper.setBreakpointsResponse(sequenceNumber); + } + + /** Handles a {@code EvaluateRequest} and returns its response. */ + private SkylarkDebuggingProtos.DebugEvent evaluate( + long sequenceNumber, SkylarkDebuggingProtos.EvaluateRequest request) + throws DebugRequestException { + return DebugEventHelper.evaluateResponse( + sequenceNumber, threadHandler.evaluate(request.getThreadId(), request.getExpression())); + } + + /** Handles a {@code ContinueExecutionRequest} and returns its response. */ + private SkylarkDebuggingProtos.DebugEvent continueExecution( + long sequenceNumber, SkylarkDebuggingProtos.ContinueExecutionRequest request) + throws DebugRequestException { + long threadId = request.getThreadId(); + if (threadId == 0) { + threadHandler.resumeAllThreads(); + return DebugEventHelper.continueExecutionResponse(sequenceNumber); + } + threadHandler.resumeThread(threadId, request.getStepping()); + return DebugEventHelper.continueExecutionResponse(sequenceNumber); + } + + private SkylarkDebuggingProtos.DebugEvent pauseThread( + long sequenceNumber, SkylarkDebuggingProtos.PauseThreadRequest request) + throws DebugRequestException { + long threadId = request.getThreadId(); + if (threadId == 0) { + threadHandler.pauseAllThreads(); + } else { + threadHandler.pauseThread(threadId); + } + return DebugEventHelper.pauseThreadResponse(sequenceNumber); + } + + /** A subclass of {@link Eval} with debugging hooks. */ + private final class DebugAwareEval extends Eval { + + DebugAwareEval(Environment env) { + super(env); + } + + @Override + public void exec(Statement st) throws EvalException, InterruptedException { + pauseIfNecessary(env, st.getLocation()); + super.exec(st); + } + } +} diff --git a/src/main/java/com/google/devtools/build/lib/skylarkdebug/server/ThreadHandler.java b/src/main/java/com/google/devtools/build/lib/skylarkdebug/server/ThreadHandler.java new file mode 100644 index 0000000000..459ebae848 --- /dev/null +++ b/src/main/java/com/google/devtools/build/lib/skylarkdebug/server/ThreadHandler.java @@ -0,0 +1,334 @@ +// Copyright 2018 The Bazel Authors. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package com.google.devtools.build.lib.skylarkdebug.server; + +import static com.google.common.collect.ImmutableList.toImmutableList; +import static com.google.common.collect.ImmutableSet.toImmutableSet; + +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableSet; +import com.google.devtools.build.lib.events.Location; +import com.google.devtools.build.lib.skylarkdebugging.SkylarkDebuggingProtos; +import com.google.devtools.build.lib.syntax.Debuggable; +import com.google.devtools.build.lib.syntax.Debuggable.ReadyToPause; +import com.google.devtools.build.lib.syntax.Environment; +import com.google.devtools.build.lib.syntax.EvalException; +import java.util.HashMap; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.Semaphore; +import javax.annotation.Nullable; +import javax.annotation.concurrent.GuardedBy; + +/** Handles all thread-related state and debugging tasks. */ +final class ThreadHandler { + + private static class ThreadState { + final long id; + final String name; + final Debuggable debuggable; + /** Non-null if the thread is currently paused. */ + @Nullable volatile PausedThreadState pausedState; + /** Determines when execution should next be paused. Non-null if currently stepping. */ + @Nullable volatile ReadyToPause readyToPause; + + ThreadState( + long id, + String name, + Debuggable debuggable, + @Nullable PausedThreadState pausedState, + @Nullable ReadyToPause readyToPause) { + this.id = id; + this.name = name; + this.debuggable = debuggable; + this.pausedState = pausedState; + this.readyToPause = readyToPause; + } + } + + /** Information about a paused thread. */ + private static class PausedThreadState { + + /** The {@link Location} where execution is currently paused. */ + final Location location; + + /** Used to block execution of threads */ + final Semaphore semaphore; + + PausedThreadState(Location location) { + this.location = location; + this.semaphore = new Semaphore(0); + } + } + + /** + * If true, all threads will pause at the earliest possible opportunity. New threads will also be + * immediately paused upon creation. + * + * <p>The debugger starts with all threads paused, until a StartDebuggingRequest is received. + */ + private volatile boolean pausingAllThreads = true; + + /** A map from thread identifiers to their state info. */ + @GuardedBy("itself") + private final Map<Long, ThreadState> threads = new HashMap<>(); + + /** All location-based breakpoints (the only type of breakpoint currently supported). */ + private volatile ImmutableSet<SkylarkDebuggingProtos.Location> breakpoints = ImmutableSet.of(); + + /** + * Threads which are set to be paused in the next checked execution step. + * + * <p>Invariant: Every thread id in this set is also in {@link #threads}, provided that we are not + * in a synchronized block on that map. + */ + private final Set<Long> threadsToPause = ConcurrentHashMap.newKeySet(); + + /** Registers a Skylark thread with the {@link ThreadHandler}. */ + void registerThread(long threadId, String threadName, Debuggable debuggable) { + doRegisterThread(threadId, threadName, debuggable); + } + + private ThreadState doRegisterThread(long threadId, String threadName, Debuggable debuggable) { + ThreadState thread = new ThreadState(threadId, threadName, debuggable, null, null); + synchronized (threads) { + threads.put(threadId, thread); + } + return thread; + } + + /** Mark all current and future threads paused. Will take effect asynchronously. */ + void pauseAllThreads() { + synchronized (threads) { + threadsToPause.addAll(threads.keySet()); + } + pausingAllThreads = true; + } + + /** Mark the given thread paused. Will take effect asynchronously. */ + void pauseThread(long threadId) throws DebugRequestException { + synchronized (threads) { + if (!threads.containsKey(threadId)) { + throw new DebugRequestException("Unknown thread: " + threadId); + } + threadsToPause.add(threadId); + } + } + + /** Called when Skylark execution for this thread is complete. */ + void unregisterThread(long threadId) { + synchronized (threads) { + threads.remove(threadId); + threadsToPause.remove(threadId); + } + } + + void setBreakpoints(ImmutableSet<SkylarkDebuggingProtos.Location> breakpoints) { + // all breakpoints cover the entire line, so unset the column number. + this.breakpoints = + breakpoints + .stream() + .map(location -> location.toBuilder().clearColumnNumber().build()) + .collect(toImmutableSet()); + } + + /** Resumes all threads. */ + void resumeAllThreads() { + threadsToPause.clear(); + pausingAllThreads = false; + synchronized (threads) { + for (ThreadState thread : threads.values()) { + // continue-all doesn't support stepping. + resumeThread(thread, SkylarkDebuggingProtos.Stepping.NONE); + } + } + } + + /** + * Unpauses the given thread if it is currently paused. Also unsets {@link #pausingAllThreads}. + */ + void resumeThread(long threadId, SkylarkDebuggingProtos.Stepping stepping) + throws DebugRequestException { + synchronized (threads) { + ThreadState thread = threads.get(threadId); + if (thread == null) { + throw new DebugRequestException(String.format("Thread %s is not running.", threadId)); + } + if (thread.pausedState == null) { + throw new DebugRequestException(String.format("Thread %s is not paused.", threadId)); + } + resumeThread(thread, stepping); + } + } + + /** + * Unpauses the given thread if it is currently paused. Also unsets {@link #pausingAllThreads}. + */ + @GuardedBy("threads") + private void resumeThread(ThreadState thread, SkylarkDebuggingProtos.Stepping stepping) { + PausedThreadState pausedState = thread.pausedState; + if (pausedState == null) { + return; + } + // once the user has resumed any thread, don't continue pausing future threads + pausingAllThreads = false; + thread.readyToPause = + thread.debuggable.stepControl(DebugEventHelper.convertSteppingEnum(stepping)); + pausedState.semaphore.release(); + thread.pausedState = null; + } + + void pauseIfNecessary(Environment env, Location location, DebugServerTransport transport) { + if (shouldPauseCurrentThread(env, location)) { + pauseCurrentThread(env, location, transport); + } + } + + ImmutableList<SkylarkDebuggingProtos.Thread> listThreads() { + ImmutableList.Builder<SkylarkDebuggingProtos.Thread> list = ImmutableList.builder(); + synchronized (threads) { + for (ThreadState thread : threads.values()) { + list.add(getThreadProto(thread)); + } + } + return list.build(); + } + + /** Handles a {@code ListFramesRequest} and returns its response. */ + ImmutableList<SkylarkDebuggingProtos.Frame> listFrames(long threadId) + throws DebugRequestException { + Debuggable debuggable; + PausedThreadState pausedState; + synchronized (threads) { + ThreadState thread = threads.get(threadId); + if (thread == null) { + throw new DebugRequestException(String.format("Thread %s is not running.", threadId)); + } + pausedState = thread.pausedState; + if (pausedState == null) { + throw new DebugRequestException(String.format("Thread %s is not paused.", threadId)); + } + debuggable = thread.debuggable; + } + // no need to list frames within the synchronize block: threads can only be resumed in response + // to a client request, and requests are handled serially + return debuggable + .listFrames(pausedState.location) + .stream() + .map(DebugEventHelper::getFrameProto) + .collect(toImmutableList()); + } + + SkylarkDebuggingProtos.Value evaluate(long threadId, String expression) + throws DebugRequestException { + Debuggable debuggable; + synchronized (threads) { + ThreadState thread = threads.get(threadId); + if (thread == null) { + throw new DebugRequestException(String.format("Thread %s is not running.", threadId)); + } + if (thread.pausedState == null) { + throw new DebugRequestException(String.format("Thread %s is not paused.", threadId)); + } + debuggable = thread.debuggable; + } + // no need to evaluate within the synchronize block: threads can only be resumed in response + // to a client request, and requests are handled serially + try { + Object result = debuggable.evaluate(expression); + return DebuggerSerialization.getValueProto("Evaluation result", result); + } catch (EvalException | InterruptedException e) { + throw new DebugRequestException(e.getMessage()); + } + } + + /** + * Pauses the current thread's execution, blocking until it's resumed via a + * ContinueExecutionRequest. + */ + private void pauseCurrentThread( + Environment env, Location location, DebugServerTransport transport) { + long threadId = Thread.currentThread().getId(); + + SkylarkDebuggingProtos.Thread threadProto; + PausedThreadState pausedState; + synchronized (threads) { + ThreadState thread = threads.get(threadId); + if (thread == null) { + // this skylark entry point didn't call DebugServer#runWithDebugging. Now that we've hit a + // breakpoint, register it anyway. + // TODO(bazel-team): once all skylark evaluation routes through + // DebugServer#runWithDebugging, report an error here instead + String fallbackThreadName = "Untracked thread: " + threadId; + transport.postEvent(DebugEventHelper.threadStartedEvent(threadId, fallbackThreadName)); + thread = doRegisterThread(threadId, fallbackThreadName, env); + } + threadProto = getThreadProto(thread); + pausedState = new PausedThreadState(location); + thread.pausedState = pausedState; + } + + transport.postEvent(DebugEventHelper.threadPausedEvent(threadProto)); + pausedState.semaphore.acquireUninterruptibly(); + transport.postEvent(DebugEventHelper.threadContinuedEvent(threadProto)); + } + + private boolean shouldPauseCurrentThread(Environment env, Location location) { + long threadId = Thread.currentThread().getId(); + if (threadsToPause.remove(threadId) || pausingAllThreads) { + return true; + } + if (hasBreakpointAtLocation(location)) { + return true; + } + + // TODO(bazel-team): if contention becomes a problem, consider changing 'threads' to a + // concurrent map, and synchronizing on individual entries + synchronized (threads) { + ThreadState thread = threads.get(threadId); + if (thread != null && thread.readyToPause != null && thread.readyToPause.test(env)) { + return true; + } + } + return false; + } + + private boolean hasBreakpointAtLocation(Location location) { + // breakpoints is volatile, so taking a local copy + ImmutableSet<SkylarkDebuggingProtos.Location> breakpoints = this.breakpoints; + if (breakpoints.isEmpty()) { + return false; + } + SkylarkDebuggingProtos.Location locationProto = DebugEventHelper.getLocationProto(location); + // column data ignored for breakpoints + return locationProto != null + && breakpoints.contains(locationProto.toBuilder().clearColumnNumber().build()); + } + + /** Returns a {@code Thread} proto builder with information about the given thread. */ + private static SkylarkDebuggingProtos.Thread getThreadProto(ThreadState thread) { + SkylarkDebuggingProtos.Thread.Builder builder = + SkylarkDebuggingProtos.Thread.newBuilder().setId(thread.id).setName(thread.name); + + PausedThreadState pausedState = thread.pausedState; + if (pausedState != null) { + builder + .setIsPaused(true) + .setLocation(DebugEventHelper.getLocationProto(pausedState.location)); + } + return builder.build(); + } +} diff --git a/src/main/java/com/google/devtools/build/lib/syntax/DebugServer.java b/src/main/java/com/google/devtools/build/lib/syntax/DebugServer.java index f462550797..0238e51959 100644 --- a/src/main/java/com/google/devtools/build/lib/syntax/DebugServer.java +++ b/src/main/java/com/google/devtools/build/lib/syntax/DebugServer.java @@ -14,11 +14,14 @@ package com.google.devtools.build.lib.syntax; +import java.util.function.Function; + /** A debug server interface, called from core skylark code. */ public interface DebugServer { /** - * Tracks the execution of the given callable object in the debug server. + * Executes the given callable and returns its result, while making any skylark evaluation visible + * to the debugger. This method should be used to evaluate all debuggable Skylark code. * * @param env the Skylark execution environment * @param threadName the descriptive name of the thread @@ -29,6 +32,15 @@ public interface DebugServer { <T> T runWithDebugging(Environment env, String threadName, DebugCallable<T> callable) throws EvalException, InterruptedException; + /** Shuts down the debug server, closing any open sockets, etc. */ + void close(); + + /** + * Returns a custom {@link Eval} supplier used to intercept statement execution to check for + * breakpoints. + */ + Function<Environment, Eval> evalOverride(); + /** Represents an invocation that will be tracked as a thread by the Skylark debug server. */ interface DebugCallable<T> { diff --git a/src/main/java/com/google/devtools/build/lib/syntax/DebugServerUtils.java b/src/main/java/com/google/devtools/build/lib/syntax/DebugServerUtils.java index f653cd994f..808d10704a 100644 --- a/src/main/java/com/google/devtools/build/lib/syntax/DebugServerUtils.java +++ b/src/main/java/com/google/devtools/build/lib/syntax/DebugServerUtils.java @@ -16,10 +16,14 @@ package com.google.devtools.build.lib.syntax; import com.google.devtools.build.lib.syntax.DebugServer.DebugCallable; import java.util.concurrent.atomic.AtomicReference; -import java.util.function.Function; import java.util.function.Supplier; -/** A helper class for enabling/disabling skylark debugging. */ +/** + * A helper class for enabling/disabling skylark debugging. + * + * <p>{@code runWithDebuggingIfEnabled} must be called after {@code initializeDebugServer}, and + * before {@code disableDebugging}. + */ public final class DebugServerUtils { private DebugServerUtils() {} @@ -30,15 +34,20 @@ public final class DebugServerUtils { * Called at the start of a debuggable skylark session to enable debugging. The custom {@link * Eval} supplier provided should intercept statement execution to check for breakpoints. */ - public static void initializeDebugServer( - DebugServer server, Function<Environment, Eval> evalOverride) { + public static void initializeDebugServer(DebugServer server) { instance.set(server); - Eval.setEvalSupplier(evalOverride); + Eval.setEvalSupplier(server.evalOverride()); } - /** Called at the end of a debuggable skylark session to disable debugging. */ + /** + * Called at the end of a debuggable skylark session to shut down the debug server and disable + * debugging. + */ public static void disableDebugging() { - instance.set(null); + DebugServer server = instance.getAndSet(null); + if (server != null) { + server.close(); + } Eval.removeCustomEval(); } diff --git a/src/test/java/com/google/devtools/build/lib/skylarkdebug/server/BUILD b/src/test/java/com/google/devtools/build/lib/skylarkdebug/server/BUILD index 5d9f881575..7f2745a9df 100644 --- a/src/test/java/com/google/devtools/build/lib/skylarkdebug/server/BUILD +++ b/src/test/java/com/google/devtools/build/lib/skylarkdebug/server/BUILD @@ -10,6 +10,55 @@ filegroup( visibility = ["//src/test/java/com/google/devtools/build/lib:__pkg__"], ) +java_library( + name = "utils", + srcs = ["MockDebugClient.java"], + deps = [ + "//src/main/java/com/google/devtools/build/lib/skylarkdebug/proto:skylark_debugging_java_proto", + "//src/main/java/com/google/devtools/build/lib/skylarkdebug/server", + "//third_party:jsr305", + ], +) + +java_test( + name = "SkylarkDebugServerTest", + size = "medium", + srcs = ["SkylarkDebugServerTest.java"], + deps = [ + ":utils", + "//src/main/java/com/google/devtools/build/lib:events", + "//src/main/java/com/google/devtools/build/lib:syntax", + "//src/main/java/com/google/devtools/build/lib/cmdline", + "//src/main/java/com/google/devtools/build/lib/collect/nestedset", + "//src/main/java/com/google/devtools/build/lib/skylarkdebug/proto:skylark_debugging_java_proto", + "//src/main/java/com/google/devtools/build/lib/skylarkdebug/server", + "//src/main/java/com/google/devtools/build/lib/vfs", + "//src/test/java/com/google/devtools/build/lib:foundations_testutil", + "//src/test/java/com/google/devtools/build/lib:testutil", + "//third_party:guava", + "//third_party:jsr305", + "//third_party:junit4", + "//third_party:truth", + ], +) + +java_test( + name = "DebugServerTransportTest", + srcs = ["DebugServerTransportTest.java"], + deps = [ + "//src/main/java/com/google/devtools/build/lib:events", + "//src/main/java/com/google/devtools/build/lib:syntax", + "//src/main/java/com/google/devtools/build/lib/collect/nestedset", + "//src/main/java/com/google/devtools/build/lib/skylarkdebug/proto:skylark_debugging_java_proto", + "//src/main/java/com/google/devtools/build/lib/skylarkdebug/server", + "//src/test/java/com/google/devtools/build/lib:foundations_testutil", + "//third_party:guava", + "//third_party:jsr305", + "//third_party:junit4", + "//third_party:truth", + ], +) + java_test( name = "DebuggerSerializationTest", srcs = glob(["DebuggerSerializationTest.java"]), diff --git a/src/test/java/com/google/devtools/build/lib/skylarkdebug/server/DebugServerTransportTest.java b/src/test/java/com/google/devtools/build/lib/skylarkdebug/server/DebugServerTransportTest.java new file mode 100644 index 0000000000..c2b6b4e414 --- /dev/null +++ b/src/test/java/com/google/devtools/build/lib/skylarkdebug/server/DebugServerTransportTest.java @@ -0,0 +1,134 @@ +// Copyright 2018 The Bazel Authors. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package com.google.devtools.build.lib.skylarkdebug.server; + +import static com.google.common.truth.Truth.assertThat; + +import com.google.devtools.build.lib.events.EventKind; +import com.google.devtools.build.lib.events.util.EventCollectionApparatus; +import com.google.devtools.build.lib.skylarkdebugging.SkylarkDebuggingProtos.ContinueExecutionResponse; +import com.google.devtools.build.lib.skylarkdebugging.SkylarkDebuggingProtos.DebugEvent; +import com.google.devtools.build.lib.skylarkdebugging.SkylarkDebuggingProtos.DebugRequest; +import com.google.devtools.build.lib.skylarkdebugging.SkylarkDebuggingProtos.StartDebuggingRequest; +import java.io.IOException; +import java.net.InetAddress; +import java.net.InetSocketAddress; +import java.net.ServerSocket; +import java.net.Socket; +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; + +/** Unit tests for {@link DebugServerTransport}. */ +@RunWith(JUnit4.class) +public class DebugServerTransportTest { + + private static final ExecutorService executor = Executors.newFixedThreadPool(1); + + private final EventCollectionApparatus events = + new EventCollectionApparatus(EventKind.ALL_EVENTS); + + /** A simple debug client for testing purposes. */ + private static class MockDebugClient { + + Socket clientSocket; + + void connect(Duration timeout, ServerSocket serverSocket) { + long startTimeMillis = System.currentTimeMillis(); + IOException exception = null; + while (System.currentTimeMillis() - startTimeMillis < timeout.toMillis()) { + try { + clientSocket = new Socket(); + clientSocket.connect( + new InetSocketAddress(serverSocket.getInetAddress(), serverSocket.getLocalPort()), + 100); + return; + } catch (IOException e) { + exception = e; + } + } + throw new RuntimeException("Couldn't connect to the debug server", exception); + } + + List<DebugEvent> readEvents() throws Exception { + List<DebugEvent> events = new ArrayList<>(); + while (clientSocket.getInputStream().available() != 0) { + events.add(DebugEvent.parseDelimitedFrom(clientSocket.getInputStream())); + } + return events; + } + + void sendRequest(DebugRequest request) throws IOException { + request.writeDelimitedTo(clientSocket.getOutputStream()); + } + } + + @Before + public void setup() { + events.setFailFast(true); + } + + @Test + public void testConnectAndReceiveRequest() throws Exception { + ServerSocket serverSocket = new ServerSocket(0, 1, InetAddress.getByName(null)); + Future<DebugServerTransport> future = + executor.submit( + () -> DebugServerTransport.createAndWaitForClient(events.reporter(), serverSocket)); + MockDebugClient client = new MockDebugClient(); + client.connect(Duration.ofSeconds(10), serverSocket); + + DebugServerTransport serverTransport = future.get(10, TimeUnit.SECONDS); + assertThat(serverTransport).isNotNull(); + DebugRequest request = + DebugRequest.newBuilder() + .setSequenceNumber(10) + .setStartDebugging(StartDebuggingRequest.getDefaultInstance()) + .build(); + client.sendRequest(request); + + assertThat(serverTransport.readClientRequest()).isEqualTo(request); + serverTransport.close(); + } + + @Test + public void testConnectAndPostEvent() throws Exception { + ServerSocket serverSocket = new ServerSocket(0, 1, InetAddress.getByName(null)); + Future<DebugServerTransport> future = + executor.submit( + () -> DebugServerTransport.createAndWaitForClient(events.reporter(), serverSocket)); + MockDebugClient client = new MockDebugClient(); + client.connect(Duration.ofSeconds(10), serverSocket); + + DebugServerTransport serverTransport = future.get(10, TimeUnit.SECONDS); + assertThat(serverTransport).isNotNull(); + DebugEvent event = + DebugEvent.newBuilder() + .setSequenceNumber(10) + .setContinueExecution(ContinueExecutionResponse.getDefaultInstance()) + .build(); + serverTransport.postEvent(event); + + assertThat(client.readEvents()).containsExactly(event); + serverTransport.close(); + } +} diff --git a/src/test/java/com/google/devtools/build/lib/skylarkdebug/server/MockDebugClient.java b/src/test/java/com/google/devtools/build/lib/skylarkdebug/server/MockDebugClient.java new file mode 100644 index 0000000000..49f197d8d8 --- /dev/null +++ b/src/test/java/com/google/devtools/build/lib/skylarkdebug/server/MockDebugClient.java @@ -0,0 +1,157 @@ +// Copyright 2018 The Bazel Authors. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package com.google.devtools.build.lib.skylarkdebug.server; + +import com.google.devtools.build.lib.skylarkdebugging.SkylarkDebuggingProtos.DebugEvent; +import com.google.devtools.build.lib.skylarkdebugging.SkylarkDebuggingProtos.DebugRequest; +import java.io.IOException; +import java.net.InetSocketAddress; +import java.net.ServerSocket; +import java.net.Socket; +import java.time.Duration; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.function.Predicate; +import javax.annotation.Nullable; + +/** A basic implementation of a skylark debugging client, for use in integration tests. */ +class MockDebugClient { + + private static final int RESPONSE_TIMEOUT_MILLIS = 10000; + private static final ExecutorService readTaskExecutor = Executors.newFixedThreadPool(1); + + private Socket clientSocket; + + final List<DebugEvent> unnumberedEvents = new ArrayList<>(); + final Map<Long, DebugEvent> responses = new HashMap<>(); + + private Future<?> readTask; + + /** Connects to the debug server, and starts listening for events. */ + void connect(ServerSocket serverSocket, Duration timeout) { + long startTimeMillis = System.currentTimeMillis(); + IOException exception = null; + while (System.currentTimeMillis() - startTimeMillis < timeout.toMillis()) { + try { + clientSocket = new Socket(); + clientSocket.connect( + new InetSocketAddress(serverSocket.getInetAddress(), serverSocket.getLocalPort()), 100); + readTask = + readTaskExecutor.submit( + () -> { + while (true) { + eventReceived(DebugEvent.parseDelimitedFrom(clientSocket.getInputStream())); + } + }); + return; + } catch (IOException e) { + exception = e; + } + } + throw new RuntimeException("Couldn't connect to the debug server", exception); + } + + void close() throws IOException { + if (clientSocket != null) { + clientSocket.close(); + } + if (readTask != null) { + readTask.cancel(true); + } + } + + /** + * Blocks waiting for an unnumbered event (not a direct response to a request). Returns null if no + * event arrives before the timeout. + */ + @Nullable + DebugEvent waitForEvent(Predicate<DebugEvent> predicate, Duration timeout) { + long startTime = System.currentTimeMillis(); + synchronized (unnumberedEvents) { + while (unnumberedEvents.stream().noneMatch(predicate) + && System.currentTimeMillis() - startTime < timeout.toMillis()) { + try { + unnumberedEvents.wait(timeout.toMillis()); + } catch (InterruptedException e) { + throw new AssertionError(e); + } + } + } + return unnumberedEvents.stream().filter(predicate).findFirst().orElse(null); + } + + /** + * Sends a {@link DebugRequest} to the server, and blocks waiting for a response. + * + * @return the {@link DebugEvent} response from the server, or null if no response was received. + */ + @Nullable + DebugEvent sendRequestAndWaitForResponse(DebugRequest request) throws IOException { + request.writeDelimitedTo(clientSocket.getOutputStream()); + clientSocket.getOutputStream().flush(); + return waitForResponse(request.getSequenceNumber()); + } + + private void eventReceived(DebugEvent event) { + if (event.getSequenceNumber() == 0) { + synchronized (unnumberedEvents) { + unnumberedEvents.add(event); + unnumberedEvents.notifyAll(); + } + return; + } + synchronized (responses) { + DebugEvent existing = responses.put(event.getSequenceNumber(), event); + if (existing != null) { + throw new AssertionError( + "There's already an event in the response queue corresponding to sequence number " + + event.getSequenceNumber()); + } + responses.notifyAll(); + } + } + + /** + * Wait for a response from the debug server. Returns null if no response was received, or this + * thread was interrupted. + */ + @Nullable + private DebugEvent waitForResponse(long sequence) { + DebugEvent response = null; + long startTime = System.currentTimeMillis(); + synchronized (responses) { + while (response == null && shouldWaitForResponse(startTime)) { + try { + responses.wait(1000); + } catch (InterruptedException e) { + throw new AssertionError(e); + } + response = responses.remove(sequence); + } + } + return response; + } + + private boolean shouldWaitForResponse(long startTime) { + return clientSocket.isConnected() + && !readTask.isDone() + && System.currentTimeMillis() - startTime < RESPONSE_TIMEOUT_MILLIS; + } +} diff --git a/src/test/java/com/google/devtools/build/lib/skylarkdebug/server/SkylarkDebugServerTest.java b/src/test/java/com/google/devtools/build/lib/skylarkdebug/server/SkylarkDebugServerTest.java new file mode 100644 index 0000000000..d6674931fb --- /dev/null +++ b/src/test/java/com/google/devtools/build/lib/skylarkdebug/server/SkylarkDebugServerTest.java @@ -0,0 +1,572 @@ +// Copyright 2018 The Bazel Authors. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package com.google.devtools.build.lib.skylarkdebug.server; + +import static com.google.common.truth.Truth.assertThat; + +import com.google.common.collect.ImmutableList; +import com.google.devtools.build.lib.events.EventKind; +import com.google.devtools.build.lib.events.util.EventCollectionApparatus; +import com.google.devtools.build.lib.skylarkdebugging.SkylarkDebuggingProtos; +import com.google.devtools.build.lib.skylarkdebugging.SkylarkDebuggingProtos.Breakpoint; +import com.google.devtools.build.lib.skylarkdebugging.SkylarkDebuggingProtos.ContinueExecutionRequest; +import com.google.devtools.build.lib.skylarkdebugging.SkylarkDebuggingProtos.DebugEvent; +import com.google.devtools.build.lib.skylarkdebugging.SkylarkDebuggingProtos.DebugRequest; +import com.google.devtools.build.lib.skylarkdebugging.SkylarkDebuggingProtos.EvaluateRequest; +import com.google.devtools.build.lib.skylarkdebugging.SkylarkDebuggingProtos.Frame; +import com.google.devtools.build.lib.skylarkdebugging.SkylarkDebuggingProtos.ListFramesRequest; +import com.google.devtools.build.lib.skylarkdebugging.SkylarkDebuggingProtos.ListFramesResponse; +import com.google.devtools.build.lib.skylarkdebugging.SkylarkDebuggingProtos.ListThreadsRequest; +import com.google.devtools.build.lib.skylarkdebugging.SkylarkDebuggingProtos.ListThreadsResponse; +import com.google.devtools.build.lib.skylarkdebugging.SkylarkDebuggingProtos.Location; +import com.google.devtools.build.lib.skylarkdebugging.SkylarkDebuggingProtos.Scope; +import com.google.devtools.build.lib.skylarkdebugging.SkylarkDebuggingProtos.SetBreakpointsRequest; +import com.google.devtools.build.lib.skylarkdebugging.SkylarkDebuggingProtos.StartDebuggingRequest; +import com.google.devtools.build.lib.skylarkdebugging.SkylarkDebuggingProtos.StartDebuggingResponse; +import com.google.devtools.build.lib.skylarkdebugging.SkylarkDebuggingProtos.Stepping; +import com.google.devtools.build.lib.syntax.BuildFileAST; +import com.google.devtools.build.lib.syntax.DebugServerUtils; +import com.google.devtools.build.lib.syntax.Environment; +import com.google.devtools.build.lib.syntax.Mutability; +import com.google.devtools.build.lib.syntax.ParserInputSource; +import com.google.devtools.build.lib.syntax.SkylarkList; +import com.google.devtools.build.lib.testutil.Scratch; +import com.google.devtools.build.lib.vfs.FileSystemUtils; +import com.google.devtools.build.lib.vfs.Path; +import java.io.IOException; +import java.net.InetAddress; +import java.net.ServerSocket; +import java.time.Duration; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; + +/** Integration tests for {@link SkylarkDebugServer}. */ +@RunWith(JUnit4.class) +public class SkylarkDebugServerTest { + + private final ExecutorService executor = Executors.newFixedThreadPool(2); + private final Scratch scratch = new Scratch(); + private final EventCollectionApparatus events = + new EventCollectionApparatus(EventKind.ALL_EVENTS); + + private MockDebugClient client; + private SkylarkDebugServer server; + + @Before + public void setUpServerAndClient() throws Exception { + ServerSocket serverSocket = new ServerSocket(0, 1, InetAddress.getByName(null)); + Future<SkylarkDebugServer> future = + executor.submit( + () -> SkylarkDebugServer.createAndWaitForConnection(events.reporter(), serverSocket)); + client = new MockDebugClient(); + client.connect(serverSocket, Duration.ofSeconds(10)); + + server = future.get(10, TimeUnit.SECONDS); + assertThat(server).isNotNull(); + DebugServerUtils.initializeDebugServer(server); + } + + @After + public void shutDown() throws Exception { + if (client != null) { + client.close(); + } + if (server != null) { + server.close(); + } + } + + @Test + public void testStartDebuggingResponseReceived() throws Exception { + DebugEvent response = + client.sendRequestAndWaitForResponse( + DebugRequest.newBuilder() + .setSequenceNumber(1) + .setStartDebugging(StartDebuggingRequest.newBuilder()) + .build()); + assertThat(response) + .isEqualTo( + DebugEvent.newBuilder() + .setSequenceNumber(1) + .setStartDebugging(StartDebuggingResponse.newBuilder().build()) + .build()); + } + + @Test + public void testThreadRegisteredEvents() throws Exception { + sendStartDebuggingRequest(); + String threadName = Thread.currentThread().getName(); + long threadId = Thread.currentThread().getId(); + DebugServerUtils.runWithDebuggingIfEnabled(newEnvironment(), () -> threadName, () -> true); + + client.waitForEvent(DebugEvent::hasThreadEnded, Duration.ofSeconds(5)); + + assertThat(client.unnumberedEvents) + .containsExactly( + DebugEventHelper.threadStartedEvent(threadId, threadName), + DebugEventHelper.threadEndedEvent(threadId, threadName)); + } + + @Test + public void testPausedUntilStartDebuggingRequestReceived() throws Exception { + BuildFileAST buildFile = parseBuildFile("/a/build/file/BUILD", "x = [1,2,3]"); + Environment env = newEnvironment(); + + Thread evaluationThread = execInWorkerThread(buildFile, env); + String threadName = evaluationThread.getName(); + long threadId = evaluationThread.getId(); + + // wait for BUILD evaluation to start + client.waitForEvent(DebugEvent::hasThreadPaused, Duration.ofSeconds(5)); + + assertThat(listThreads().getThreadList()) + .containsExactly( + SkylarkDebuggingProtos.Thread.newBuilder() + .setId(threadId) + .setName(threadName) + .setLocation( + DebugEventHelper.getLocationProto( + buildFile.getStatements().get(0).getLocation())) + .setIsPaused(true) + .build()); + + sendStartDebuggingRequest(); + client.waitForEvent(DebugEvent::hasThreadEnded, Duration.ofSeconds(5)); + assertThat(listThreads().getThreadList()).isEmpty(); + assertThat(client.unnumberedEvents) + .containsAllOf( + DebugEventHelper.threadContinuedEvent( + SkylarkDebuggingProtos.Thread.newBuilder() + .setName(threadName) + .setId(threadId) + .build()), + DebugEventHelper.threadEndedEvent(threadId, threadName)); + } + + @Test + public void testPauseAtBreakpoint() throws Exception { + sendStartDebuggingRequest(); + BuildFileAST buildFile = parseBuildFile("/a/build/file/BUILD", "x = [1,2,3]", "y = [2,3,4]"); + Environment env = newEnvironment(); + + Location breakpoint = + Location.newBuilder().setLineNumber(2).setPath("/a/build/file/BUILD").build(); + setBreakpoints(ImmutableList.of(breakpoint)); + + Thread evaluationThread = execInWorkerThread(buildFile, env); + String threadName = evaluationThread.getName(); + long threadId = evaluationThread.getId(); + + // wait for breakpoint to be hit + client.waitForEvent(DebugEvent::hasThreadPaused, Duration.ofSeconds(5)); + + assertThat(listThreads().getThreadList()) + .containsExactly( + SkylarkDebuggingProtos.Thread.newBuilder() + .setId(threadId) + .setName(threadName) + .setLocation(breakpoint.toBuilder().setColumnNumber(1)) + .setIsPaused(true) + .build()); + } + + @Test + public void testListFramesForInvalidThread() throws Exception { + sendStartDebuggingRequest(); + DebugEvent event = + client.sendRequestAndWaitForResponse( + DebugRequest.newBuilder() + .setSequenceNumber(1) + .setListFrames(ListFramesRequest.newBuilder().setThreadId(20).build()) + .build()); + assertThat(event.hasError()).isTrue(); + assertThat(event.getError().getMessage()).contains("Thread 20 is not running"); + } + + @Test + public void testSimpleListFramesRequest() throws Exception { + sendStartDebuggingRequest(); + BuildFileAST buildFile = parseBuildFile("/a/build/file/BUILD", "x = [1,2,3]", "y = [2,3,4]"); + Environment env = newEnvironment(); + + Location breakpoint = + Location.newBuilder().setLineNumber(2).setPath("/a/build/file/BUILD").build(); + setBreakpoints(ImmutableList.of(breakpoint)); + + Thread evaluationThread = execInWorkerThread(buildFile, env); + long threadId = evaluationThread.getId(); + + // wait for breakpoint to be hit + client.waitForEvent(DebugEvent::hasThreadPaused, Duration.ofSeconds(5)); + + ListFramesResponse frames = listFrames(threadId); + assertThat(frames.getFrameCount()).isEqualTo(1); + assertThat(frames.getFrame(0)) + .isEqualTo( + Frame.newBuilder() + .setFunctionName("<top level>") + .setLocation(breakpoint.toBuilder().setColumnNumber(1)) + .addScope( + Scope.newBuilder() + .setName("global") + .addBinding( + DebuggerSerialization.getValueProto( + "x", SkylarkList.createImmutable(ImmutableList.of(1, 2, 3))))) + .build()); + } + + @Test + public void testListFramesShadowedBinding() throws Exception { + sendStartDebuggingRequest(); + BuildFileAST bzlFile = + parseSkylarkFile( + "/a/build/file/test.bzl", + "a = 1", + "c = 3", + "def fn():", + " a = 2", + " b = 1", + " b + 1", + "fn()"); + Environment env = newEnvironment(); + + Location breakpoint = + Location.newBuilder().setPath("/a/build/file/test.bzl").setLineNumber(6).build(); + setBreakpoints(ImmutableList.of(breakpoint)); + + Thread evaluationThread = execInWorkerThread(bzlFile, env); + long threadId = evaluationThread.getId(); + + // wait for breakpoint to be hit + client.waitForEvent(DebugEvent::hasThreadPaused, Duration.ofSeconds(5)); + + ListFramesResponse frames = listFrames(threadId); + assertThat(frames.getFrameCount()).isEqualTo(2); + + assertThat(frames.getFrame(0)) + .isEqualTo( + Frame.newBuilder() + .setFunctionName("fn") + .setLocation(breakpoint.toBuilder().setColumnNumber(3)) + .addScope( + Scope.newBuilder() + .setName("local") + .addBinding(DebuggerSerialization.getValueProto("a", 2)) + .addBinding(DebuggerSerialization.getValueProto("b", 1))) + .addScope( + Scope.newBuilder() + .setName("global") + .addBinding(DebuggerSerialization.getValueProto("c", 3)) + .addBinding(DebuggerSerialization.getValueProto("fn", env.lookup("fn")))) + .build()); + + assertThat(frames.getFrame(1)) + .isEqualTo( + Frame.newBuilder() + .setFunctionName("<top level>") + .setLocation( + Location.newBuilder() + .setPath("/a/build/file/test.bzl") + .setLineNumber(7) + .setColumnNumber(1)) + .addScope( + Scope.newBuilder() + .setName("global") + .addBinding(DebuggerSerialization.getValueProto("a", 1)) + .addBinding(DebuggerSerialization.getValueProto("c", 3)) + .addBinding(DebuggerSerialization.getValueProto("fn", env.lookup("fn")))) + .build()); + } + + @Test + public void testEvaluateRequest() throws Exception { + sendStartDebuggingRequest(); + BuildFileAST buildFile = parseBuildFile("/a/build/file/BUILD", "x = [1,2,3]", "y = [2,3,4]"); + Environment env = newEnvironment(); + + Location breakpoint = + Location.newBuilder().setLineNumber(2).setPath("/a/build/file/BUILD").build(); + setBreakpoints(ImmutableList.of(breakpoint)); + + Thread evaluationThread = execInWorkerThread(buildFile, env); + long threadId = evaluationThread.getId(); + + // wait for breakpoint to be hit + client.waitForEvent(DebugEvent::hasThreadPaused, Duration.ofSeconds(5)); + + DebugEvent response = + client.sendRequestAndWaitForResponse( + DebugRequest.newBuilder() + .setSequenceNumber(123) + .setEvaluate( + EvaluateRequest.newBuilder() + .setThreadId(threadId) + .setExpression("x[1]") + .build()) + .build()); + assertThat(response.hasEvaluate()).isTrue(); + assertThat(response.getEvaluate().getResult()) + .isEqualTo(DebuggerSerialization.getValueProto("Evaluation result", 2)); + } + + @Test + public void testEvaluateRequestThrowingException() throws Exception { + sendStartDebuggingRequest(); + BuildFileAST buildFile = parseBuildFile("/a/build/file/BUILD", "x = [1,2,3]", "y = [2,3,4]"); + Environment env = newEnvironment(); + + Location breakpoint = + Location.newBuilder().setLineNumber(2).setPath("/a/build/file/BUILD").build(); + setBreakpoints(ImmutableList.of(breakpoint)); + + Thread evaluationThread = execInWorkerThread(buildFile, env); + long threadId = evaluationThread.getId(); + + // wait for breakpoint to be hit + client.waitForEvent(DebugEvent::hasThreadPaused, Duration.ofSeconds(5)); + + DebugEvent response = + client.sendRequestAndWaitForResponse( + DebugRequest.newBuilder() + .setSequenceNumber(123) + .setEvaluate( + EvaluateRequest.newBuilder() + .setThreadId(threadId) + .setExpression("z[0]") + .build()) + .build()); + assertThat(response.hasError()).isTrue(); + assertThat(response.getError().getMessage()).isEqualTo("name 'z' is not defined"); + } + + @Test + public void testStepIntoFunction() throws Exception { + sendStartDebuggingRequest(); + BuildFileAST bzlFile = + parseSkylarkFile( + "/a/build/file/test.bzl", + "def fn():", + " a = 2", + " return a", + "x = fn()", + "y = [2,3,4]"); + Environment env = newEnvironment(); + + Location breakpoint = + Location.newBuilder().setLineNumber(4).setPath("/a/build/file/test.bzl").build(); + setBreakpoints(ImmutableList.of(breakpoint)); + + Thread evaluationThread = execInWorkerThread(bzlFile, env); + long threadId = evaluationThread.getId(); + + // wait for breakpoint to be hit + client.waitForEvent(DebugEvent::hasThreadPaused, Duration.ofSeconds(5)); + + assertThat(listThreads().getThread(0).getLocation().getLineNumber()).isEqualTo(4); + + client.unnumberedEvents.clear(); + client.sendRequestAndWaitForResponse( + DebugRequest.newBuilder() + .setSequenceNumber(2) + .setContinueExecution( + ContinueExecutionRequest.newBuilder() + .setThreadId(threadId) + .setStepping(Stepping.INTO) + .build()) + .build()); + client.waitForEvent(DebugEvent::hasThreadPaused, Duration.ofSeconds(5)); + + // check we're paused inside the function + assertThat(listFrames(threadId).getFrameCount()).isEqualTo(2); + + // and verify the exact line index as well + ListThreadsResponse threads = listThreads(); + assertThat(threads.getThreadList()).hasSize(1); + assertThat(threads.getThread(0).getIsPaused()).isTrue(); + assertThat(threads.getThread(0).getLocation().getLineNumber()).isEqualTo(2); + } + + @Test + public void testStepOverFunction() throws Exception { + sendStartDebuggingRequest(); + BuildFileAST bzlFile = + parseSkylarkFile( + "/a/build/file/test.bzl", + "def fn():", + " a = 2", + " return a", + "x = fn()", + "y = [2,3,4]"); + Environment env = newEnvironment(); + + Location breakpoint = + Location.newBuilder().setLineNumber(4).setPath("/a/build/file/test.bzl").build(); + setBreakpoints(ImmutableList.of(breakpoint)); + + Thread evaluationThread = execInWorkerThread(bzlFile, env); + long threadId = evaluationThread.getId(); + + // wait for breakpoint to be hit + client.waitForEvent(DebugEvent::hasThreadPaused, Duration.ofSeconds(5)); + + assertThat(listThreads().getThread(0).getLocation().getLineNumber()).isEqualTo(4); + + client.unnumberedEvents.clear(); + client.sendRequestAndWaitForResponse( + DebugRequest.newBuilder() + .setSequenceNumber(2) + .setContinueExecution( + ContinueExecutionRequest.newBuilder() + .setThreadId(threadId) + .setStepping(Stepping.OVER) + .build()) + .build()); + client.waitForEvent(DebugEvent::hasThreadPaused, Duration.ofSeconds(5)); + + ListThreadsResponse threads = listThreads(); + assertThat(threads.getThreadList()).hasSize(1); + assertThat(threads.getThread(0).getIsPaused()).isTrue(); + assertThat(threads.getThread(0).getLocation().getLineNumber()).isEqualTo(5); + } + + @Test + public void testStepOutOfFunction() throws Exception { + sendStartDebuggingRequest(); + BuildFileAST bzlFile = + parseSkylarkFile( + "/a/build/file/test.bzl", + "def fn():", + " a = 2", + " return a", + "x = fn()", + "y = [2,3,4]"); + Environment env = newEnvironment(); + + Location breakpoint = + Location.newBuilder().setLineNumber(2).setPath("/a/build/file/test.bzl").build(); + setBreakpoints(ImmutableList.of(breakpoint)); + + Thread evaluationThread = execInWorkerThread(bzlFile, env); + long threadId = evaluationThread.getId(); + + // wait for breakpoint to be hit + client.waitForEvent(DebugEvent::hasThreadPaused, Duration.ofSeconds(5)); + + assertThat(listFrames(threadId).getFrameCount()).isEqualTo(2); + + client.unnumberedEvents.clear(); + client.sendRequestAndWaitForResponse( + DebugRequest.newBuilder() + .setSequenceNumber(2) + .setContinueExecution( + ContinueExecutionRequest.newBuilder() + .setThreadId(threadId) + .setStepping(Stepping.OUT) + .build()) + .build()); + client.waitForEvent(DebugEvent::hasThreadPaused, Duration.ofSeconds(5)); + + ListThreadsResponse threads = listThreads(); + assertThat(threads.getThreadList()).hasSize(1); + assertThat(threads.getThread(0).getIsPaused()).isTrue(); + assertThat(threads.getThread(0).getLocation().getLineNumber()).isEqualTo(5); + } + + private void setBreakpoints(Iterable<Location> locations) throws Exception { + SetBreakpointsRequest.Builder request = SetBreakpointsRequest.newBuilder(); + locations.forEach(l -> request.addBreakpoint(Breakpoint.newBuilder().setLocation(l))); + DebugEvent response = + client.sendRequestAndWaitForResponse( + DebugRequest.newBuilder().setSequenceNumber(10).setSetBreakpoints(request).build()); + assertThat(response.hasSetBreakpoints()).isTrue(); + assertThat(response.getSequenceNumber()).isEqualTo(10); + } + + private void sendStartDebuggingRequest() throws Exception { + client.sendRequestAndWaitForResponse( + DebugRequest.newBuilder() + .setSequenceNumber(1) + .setStartDebugging(StartDebuggingRequest.newBuilder()) + .build()); + } + + private ListThreadsResponse listThreads() throws Exception { + DebugEvent event = + client.sendRequestAndWaitForResponse( + DebugRequest.newBuilder() + .setSequenceNumber(1) + .setListThreads(ListThreadsRequest.newBuilder()) + .build()); + assertThat(event.hasListThreads()).isTrue(); + assertThat(event.getSequenceNumber()).isEqualTo(1); + return event.getListThreads(); + } + + private ListFramesResponse listFrames(long threadId) throws Exception { + DebugEvent event = + client.sendRequestAndWaitForResponse( + DebugRequest.newBuilder() + .setSequenceNumber(1) + .setListFrames(ListFramesRequest.newBuilder().setThreadId(threadId).build()) + .build()); + assertThat(event.hasListFrames()).isTrue(); + assertThat(event.getSequenceNumber()).isEqualTo(1); + return event.getListFrames(); + } + + private static Environment newEnvironment() { + Mutability mutability = Mutability.create("test"); + return Environment.builder(mutability).useDefaultSemantics().build(); + } + + private BuildFileAST parseBuildFile(String path, String... lines) throws IOException { + Path file = scratch.file(path, lines); + byte[] bytes = FileSystemUtils.readWithKnownFileSize(file, file.getFileSize()); + ParserInputSource inputSource = ParserInputSource.create(bytes, file.asFragment()); + return BuildFileAST.parseBuildFile(inputSource, events.reporter()); + } + + private BuildFileAST parseSkylarkFile(String path, String... lines) throws IOException { + Path file = scratch.file(path, lines); + byte[] bytes = FileSystemUtils.readWithKnownFileSize(file, file.getFileSize()); + ParserInputSource inputSource = ParserInputSource.create(bytes, file.asFragment()); + return BuildFileAST.parseSkylarkFile(inputSource, events.reporter()); + } + + /** + * Creates and starts a worker thread executing the given {@link BuildFileAST} in the given + * environment. + */ + private Thread execInWorkerThread(BuildFileAST ast, Environment env) { + Thread thread = + new Thread( + () -> { + try { + ast.exec(env, events.collector()); + } catch (Throwable e) { + throw new AssertionError(e); + } + }); + thread.start(); + return thread; + } +} |