Skip to content

Commit

Permalink
Implement Debugger
Browse files Browse the repository at this point in the history
  • Loading branch information
adpi2 committed Feb 17, 2025
1 parent 3d7023b commit 81146a6
Show file tree
Hide file tree
Showing 6 changed files with 158 additions and 36 deletions.
42 changes: 18 additions & 24 deletions compiler/test/dotty/tools/debug/DebugStepAssert.scala
Original file line number Diff line number Diff line change
@@ -1,11 +1,7 @@
package dotty.tools.debug

import scala.util.Using
import scala.io.Source
import java.nio.charset.StandardCharsets
import scala.io.Codec
import com.sun.jdi.Location
import dotty.tools.io.JFile
import java.nio.file.Files
import dotty.tools.readLines

/**
Expand All @@ -19,38 +15,36 @@ private[debug] object DebugStepAssert:
def parseCheckFile(checkFile: JFile): Seq[DebugStepAssert[?]] =
val sym = "[a-zA-Z0-9$.]+"
val line = "\\d+"
val break = s"break ($sym) ($line)".r
val step = s"step ($sym|$line)".r
val next = s"next ($sym|$line)".r
val comment = "// .*".r
val empty = "\\w*".r
val trailing = s"\\s*(?:\\/\\/.*)?".r // empty or comment
val break = s"break ($sym) ($line)$trailing".r
val step = s"step ($sym|$line)$trailing".r
val next = s"next ($sym|$line)$trailing".r
readLines(checkFile).flatMap:
case break(className , lineStr) =>
val line = lineStr.toInt
Some(DebugStepAssert(Break(className, line), checkFrame(className, line)))
Some(DebugStepAssert(Break(className, line), checkClassAndLine(className, line)))
case step(pattern) => Some(DebugStepAssert(Step, checkLineOrMethod(pattern)))
case next(pattern) => Some(DebugStepAssert(Step, checkLineOrMethod(pattern)))
case comment() | empty() => None
case next(pattern) => Some(DebugStepAssert(Next, checkLineOrMethod(pattern)))
case trailing() => None
case invalid => throw new Exception(s"Cannot parse debug step: $invalid")

private def checkFrame(className: String, line: Int)(frame: Frame): Unit =
assert(className.matches(frame.className))
assert(frame.line == line)
private def checkClassAndLine(className: String, line: Int)(location: Location): Unit =
assert(className == location.declaringType.name, s"obtained ${location.declaringType.name}, expected ${className}")
checkLine(line)(location)

private def checkLineOrMethod(pattern: String): Frame => Unit =
private def checkLineOrMethod(pattern: String): Location => Unit =
if "(\\d+)".r.matches(pattern) then checkLine(pattern.toInt) else checkMethod(pattern)

private def checkLine(line: Int)(frame: Frame): Unit = assert(frame.line == line)
private def checkLine(line: Int)(location: Location): Unit =
assert(location.lineNumber == line, s"obtained ${location.lineNumber}, expected $line")

private def checkMethod(method: String)(frame: Frame): Unit =
assert(method.matches(s"${frame.className}.${frame.methodName}"))
private def checkMethod(methodName: String)(location: Location): Unit = assert(methodName == location.method.name)
end DebugStepAssert

private[debug] enum DebugStep[T]:
case Break(className: String, line: Int) extends DebugStep[Frame]
case Step extends DebugStep[Frame]
case Next extends DebugStep[Frame]
case Break(className: String, line: Int) extends DebugStep[Location]
case Step extends DebugStep[Location]
case Next extends DebugStep[Location]

private[debug] case class Frame(className: String, methodName: String, line: Int)


44 changes: 39 additions & 5 deletions compiler/test/dotty/tools/debug/DebugTests.scala
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package dotty.tools.debug

import com.sun.jdi.*
import dotty.Properties
import dotty.tools.dotc.reporting.TestReporter
import dotty.tools.io.JFile
Expand All @@ -12,10 +13,9 @@ class DebugTests:
import DebugTests.*
@Test def debug: Unit =
implicit val testGroup: TestGroup = TestGroup("debug")
// compileFile("tests/debug/tailrec.scala", TestConfiguration.defaultOptions).checkDebug()
compileFilesInDir("tests/debug", TestConfiguration.defaultOptions).checkDebug()

end DebugTests

object DebugTests extends ParallelTesting:
def maxDuration = 45.seconds
def numberOfSlaves = Runtime.getRuntime().availableProcessors()
Expand Down Expand Up @@ -45,9 +45,16 @@ object DebugTests extends ParallelTesting:
val checkFile = testSource.checkFile.getOrElse(throw new Exception("Missing check file"))
val debugSteps = DebugStepAssert.parseCheckFile(checkFile)
val status = debugMain(testSource.runClassPath): debuggee =>
val debugger = Debugger(debuggee.jdiPort, maxDuration)
try debuggee.launch()
finally debugger.dispose()
val debugger = Debugger(debuggee.jdiPort, maxDuration/* , verbose = true */)
// configure the breakpoints before starting the debuggee
val breakpoints = debugSteps.map(_.step).collect { case b: DebugStep.Break => b }
for b <- breakpoints do debugger.configureBreakpoint(b.className, b.line)
try
debuggee.launch()
playDebugSteps(debugger, debugSteps/* , verbose = true */)
finally
// stop debugger to let debuggee terminate its execution
debugger.dispose()
status match
case Success(output) => ()
case Failure(output) =>
Expand All @@ -60,5 +67,32 @@ object DebugTests extends ParallelTesting:
case Timeout =>
echo("failed because test " + testSource.title + " timed out")
failTestSource(testSource, TimeoutFailure(testSource.title))
end verifyDebug

private def playDebugSteps(debugger: Debugger, steps: Seq[DebugStepAssert[?]], verbose: Boolean = false): Unit =
import scala.language.unsafeNulls

var thread: ThreadReference = null
def location = thread.frame(0).location

for case step <- steps do
import DebugStep.*
step match
case DebugStepAssert(Break(className, line), assert) =>
// continue if paused
if thread != null then
debugger.continue(thread)
thread = null
thread = debugger.break()
if verbose then println(s"break ${location.declaringType.name} ${location.lineNumber}")
assert(location)
case DebugStepAssert(Next, assert) =>
thread = debugger.next(thread)
if verbose then println(s"next ${location.lineNumber}")
assert(location)
case DebugStepAssert(Step, assert) =>
thread = debugger.step(thread)
if verbose then println(s"step ${location.lineNumber}")
assert(location)
end playDebugSteps
end DebugTest
101 changes: 98 additions & 3 deletions compiler/test/dotty/tools/debug/Debugger.scala
Original file line number Diff line number Diff line change
@@ -1,11 +1,106 @@
package dotty.tools.debug

import com.sun.jdi.*
import scala.jdk.CollectionConverters.*
import com.sun.jdi.event.*
import com.sun.jdi.request.*

import java.lang.ref.Reference
import java.util.concurrent.LinkedBlockingQueue
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicReference
import scala.concurrent.duration.Duration
import scala.jdk.CollectionConverters.*

class Debugger(vm: VirtualMachine, maxDuration: Duration, verbose: Boolean = false):
// For some JDI events that we receive, we wait for client actions.
// Example: On a BreakpointEvent, the client may want to inspect frames and variables, before it
// decides to step in or continue.
private val pendingEvents = new LinkedBlockingQueue[Event]()

// Internal event subscriptions, to react to JDI events
// Example: add a Breakpoint on a ClassPrepareEvent
private val eventSubs = new AtomicReference(List.empty[PartialFunction[Event, Unit]])
private val eventListener = startListeningVM()

def configureBreakpoint(className: String, line: Int): Unit =
vm.classesByName(className).asScala.foreach(addBreakpoint(_, line))
// watch class preparation and add breakpoint when the class is prepared
val request = vm.eventRequestManager.createClassPrepareRequest
request.addClassFilter(className)
subscribe:
case e: ClassPrepareEvent if e.request == request => addBreakpoint(e.referenceType, line)
request.enable()

def break(): ThreadReference = receiveEvent { case e: BreakpointEvent => e.thread }

def continue(thread: ThreadReference): Unit = thread.resume()

def next(thread: ThreadReference): ThreadReference =
stepAndWait(thread, StepRequest.STEP_LINE, StepRequest.STEP_OVER)

def step(thread: ThreadReference): ThreadReference =
stepAndWait(thread, StepRequest.STEP_LINE, StepRequest.STEP_INTO)

/** stop listening and disconnect debugger */
def dispose(): Unit =
eventListener.interrupt()
vm.dispose()

private def addBreakpoint(refType: ReferenceType, line: Int): Unit =
for location <- refType.locationsOfLine(line).asScala do
if verbose then println(s"Adding breakpoint in $location")
val breakpoint = vm.eventRequestManager.createBreakpointRequest(location)
// suspend only the thread which triggered the event
breakpoint.setSuspendPolicy(EventRequest.SUSPEND_EVENT_THREAD)
// let's enable the breakpoint and forget about it
// we don't need to store it because we never remove any breakpoint
breakpoint.enable()

private def stepAndWait(thread: ThreadReference, size: Int, depth: Int): ThreadReference =
val request = vm.eventRequestManager.createStepRequest(thread, size, depth)
request.enable()
thread.resume()
// Because our debuggee is mono-threaded, we don't check that `e.request` is our step request.
// Indeed there can be only one step request per thread at a time.
val newThreadRef = receiveEvent { case e: StepEvent => e.thread }
request.disable()
newThreadRef

private def subscribe(f: PartialFunction[Event, Unit]): Unit =
eventSubs.updateAndGet(f :: _)

private def startListeningVM(): Thread =
val thread = Thread: () =>
var isAlive = true
try while isAlive do
val eventSet = vm.eventQueue.remove()
val subscriptions = eventSubs.get
var shouldResume = true
for event <- eventSet.iterator.asScala.toSeq do
if verbose then println(formatEvent(event))
for f <- subscriptions if f.isDefinedAt(event) do f(event)
event match
case e: (BreakpointEvent | StepEvent) =>
shouldResume = false
pendingEvents.put(e)
case _: VMDisconnectEvent => isAlive = false
case _ => ()
if shouldResume then eventSet.resume()
catch case _: InterruptedException => ()
thread.start()
thread
end startListeningVM

private def receiveEvent[T](f: PartialFunction[Event, T]): T =
// poll repeatedly until we get an event that matches the partial function or a timeout
Iterator.continually(pendingEvents.poll(maxDuration.toMillis, TimeUnit.MILLISECONDS))
.collect(f)
.next()

class Debugger(vm: VirtualMachine, maxDuration: Duration):
export vm.dispose
private def formatEvent(event: Event): String =
event match
case e: ClassPrepareEvent => s"$e ${e.referenceType}"
case e => e.toString

object Debugger:
// The socket JDI connector
Expand Down
2 changes: 1 addition & 1 deletion compiler/test/dotty/tools/vulpix/RunnerOrchestration.scala
Original file line number Diff line number Diff line change
Expand Up @@ -143,7 +143,7 @@ trait RunnerOrchestration {
end debugMain

private def startMain(classPath: String): Future[Status] =
// pass file to running process
// pass classpath to running process
process.stdin.println(classPath)

// Create a future reading the object:
Expand Down
2 changes: 1 addition & 1 deletion tests/debug/function.check
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,6 @@ step 10
break Test$ 6
step 7
step 8
next 9
next apply$mcIII$sp // specialized Lambda.apply
next 10
next 11
3 changes: 1 addition & 2 deletions tests/debug/tailrec.check
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,5 @@ step 3
break Test$ 14
step 3
step 4
// incorrect debug line
step 6
step 14
step 15

0 comments on commit 81146a6

Please sign in to comment.