Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added

### Fixed
- **Watchpoint Stacktraces, Exception Messages & Field Validation (`watch`, `catch-exception`, `poll`):** Fixed `WATCHPOINT_ACCESS_HIT` and `WATCHPOINT_MODIFY_HIT` events omitting stack frames when polling with `debroid poll --with-stacktrace`. Enriched `EXCEPTION_HIT` events to include the exception's `detailMessage` alongside its class name (`"<ExceptionClass>: <detailMessage>"`). Fixed `debroid watch` silently succeeding when given a non-existent field name on a loaded class (now returns an `INVALID_ARGUMENT` error), bound watchpoints and exception breakpoints across all loaded `ClassLoader` instances of a target class, and coordinated filtered exception resume behavior with the event loop (#111).
- **Breakpoints in Lazily-Loaded Kotlin Lambdas & Package-Aware Deduplication (`break`):** Fixed an issue where line breakpoints set on lines spanning both an enclosing class and a lazily-loaded inner or lambda class (such as click listeners, coroutines, or Jetpack Compose lambdas) missed execution when the lambda class loaded after the outer class. Also fixed `debroid break` deduplication to respect `--package`, allowing unverified breakpoints to be retried with `--package` and supporting breakpoints at the same filename and line number across distinct packages (#109).
- **Sync `inspect` Command Flags in Skill Documentation:** Updated the command reference table in `SKILL.md` to include the `--include-static` and `--include-internal` flags and default field filtering behavior for `debroid inspect` (#112).
- **Array Element Inspection in `debroid inspect`:** Fixed an issue where running `debroid inspect` on any JVM array (`IntArray`, `Object[]`, or collection backing arrays like `ArrayList.elementData`) returned an empty `fields` map. Array elements are now populated under indexed keys (`"[0]"`, `"[1]"`, ...) in `fields` and recursively inspected in `nested` when `--max-depth > 1` (#110).
Expand Down
122 changes: 74 additions & 48 deletions core/src/main/kotlin/dev/shreyaspatil/debroid/jdi/JdiSession.kt
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ class JdiSession(
private val deferredBreakpoints = ConcurrentHashMap<String, DeferredBreakpoint>()
private val deferredWatchpoints = ConcurrentHashMap<String, MutableList<DeferredWatchpoint>>()
private val deferredExceptionBreakpoints = ConcurrentHashMap<String, MutableList<DeferredExceptionBreakpoint>>()
private val exceptionRequests = ConcurrentHashMap<String, ExceptionRequest>()
private val exceptionRequests = ConcurrentHashMap<String, MutableList<ExceptionRequest>>()
private val watchpointRequests = ConcurrentHashMap<String, MutableList<WatchpointRequest>>()
private val classPrepareRequest = AtomicReference<ClassPrepareRequest?>(null)
private val activeExclusions = AtomicReference<Set<String>>(emptySet())
Expand Down Expand Up @@ -324,7 +324,6 @@ class JdiSession(
removeExceptionBreakpoint(existing.id)
}

val erm = vm.eventRequestManager()
val id = "ex_bp_${exceptionIdCounter.getAndIncrement()}"

if (className != null) {
Expand All @@ -336,7 +335,9 @@ class JdiSession(
ensureClassPrepareRequest()
return id
}
bindExceptionBreakpointForRefType(id, refTypes.first(), className, notifyCaught, notifyUncaught)
for (refType in refTypes) {
bindExceptionBreakpointForRefType(id, refType, className, notifyCaught, notifyUncaught)
}
return id
}

Expand All @@ -358,17 +359,23 @@ class JdiSession(
}
req.setSuspendPolicy(EventRequest.SUSPEND_ALL)
req.enable()
exceptionRequests[id] = req
exceptionRequests.computeIfAbsent(id) { mutableListOf() }.add(req)
}

fun setWatchpoint(className: String, fieldName: String, access: Boolean = true, modify: Boolean = true): String {
val refTypes = vm.classesByName(className)
if (refTypes.isNotEmpty() && refTypes.none { it.fieldByName(fieldName) != null }) {
throw DebugException(
ErrorCode.INVALID_ARGUMENT,
"Field '$fieldName' not found in class '$className'"
)
}

val existing = listWatchpoints().find { it.className == className && it.fieldName == fieldName }
if (existing != null) {
removeWatchpoint(existing.id)
}

val erm = vm.eventRequestManager()
val refTypes = vm.classesByName(className)
val id = "wp_${watchpointIdCounter.getAndIncrement()}"

if (refTypes.isEmpty()) {
Expand All @@ -380,13 +387,15 @@ class JdiSession(
return id
}

bindWatchpointForRefType(
id = id,
refType = refTypes.first(),
fieldName = fieldName,
access = access,
modify = modify
)
for (refType in refTypes) {
bindWatchpointForRefType(
id = id,
refType = refType,
fieldName = fieldName,
access = access,
modify = modify
)
}
return id
}

Expand Down Expand Up @@ -477,13 +486,14 @@ class JdiSession(
}

fun removeExceptionBreakpoint(id: String): Boolean {
var removed = false
val req = exceptionRequests.remove(id)
if (req != null) {
try { vm.eventRequestManager().deleteEventRequest(req) } catch (_: Exception) {}
removed = true
val requests = exceptionRequests.remove(id)
val removed = if (!requests.isNullOrEmpty()) {
requests.forEach { req ->
try { vm.eventRequestManager().deleteEventRequest(req) } catch (_: Exception) {}
}
true
} else {
removed = removeFromDeferredExceptionBreakpoints(id)
removeFromDeferredExceptionBreakpoints(id)
}
maybeDisableClassPrepareRequest()
return removed
Expand Down Expand Up @@ -547,7 +557,8 @@ class JdiSession(
private fun listBreakpoints(): List<BreakpointInfo> = activeBreakpoints.values.toList()

private fun listExceptionBreakpoints(): List<ExceptionBreakpointInfo> {
val active = exceptionRequests.map { (id, req) ->
val active = exceptionRequests.mapNotNull { (id, reqs) ->
val req = reqs.firstOrNull() ?: return@mapNotNull null
ExceptionBreakpointInfo(
id = id,
className = req.getProperty(PROP_CLASS_NAME) as? String,
Expand Down Expand Up @@ -1546,7 +1557,6 @@ class JdiSession(
}
is ExceptionEvent -> {
handleExceptionEvent(event)
true
}
is AccessWatchpointEvent -> {
handleAccessWatchpointEvent(event)
Expand Down Expand Up @@ -1684,32 +1694,14 @@ class JdiSession(
)
}

private fun handleExceptionEvent(event: ExceptionEvent) {
private fun handleExceptionEvent(event: ExceptionEvent): Boolean {
// Client-side filtering because JDWP ExceptionOnly filters can sometimes be buggy/too-broad
val expectedClassName = event.request()?.getProperty(PROP_CLASS_NAME) as? String
if (expectedClassName != null) {
val thrownClass = event.exception().referenceType()
var matches = false
var currentClass: com.sun.jdi.ClassType? = thrownClass as? com.sun.jdi.ClassType
while (currentClass != null) {
if (currentClass.name() == expectedClassName) {
matches = true
break
}
currentClass = currentClass.superclass()
}
if (!matches) {
// Also check if it's an interface (less common for exceptions, but possible)
if (thrownClass is com.sun.jdi.ClassType) {
if (thrownClass.allInterfaces().any { it.name() == expectedClassName }) {
matches = true
}
}
if (!matches) {
event.virtualMachine().resume()
return
}
}
if (
expectedClassName != null &&
!matchesExpectedExceptionClass(event.exception().referenceType(), expectedClassName)
) {
return false
}

val loc = event.location()
Expand All @@ -1721,10 +1713,42 @@ class JdiSession(
threadName = event.thread().name(),
location = "${safeSourceName(loc)}:${loc.lineNumber()}",
className = loc.declaringType().name(),
exceptionMessage = event.exception().referenceType().name(),
exceptionMessage = formatExceptionMessage(event.exception()),
stacktrace = getFramesSafely(event.thread())
)
)
return true
}

private fun matchesExpectedExceptionClass(thrownClass: ReferenceType, expectedClassName: String): Boolean {
var currentClass: com.sun.jdi.ClassType? = thrownClass as? com.sun.jdi.ClassType
while (currentClass != null) {
if (currentClass.name() == expectedClassName) {
return true
}
currentClass = currentClass.superclass()
}
return thrownClass is com.sun.jdi.ClassType &&
thrownClass.allInterfaces().any { it.name() == expectedClassName }
}

private fun formatExceptionMessage(exception: ObjectReference): String {
val className = exception.referenceType().name()
val detailMessage = try {
val field = exception.referenceType().fieldByName("detailMessage")
if (field != null) {
(exception.getValue(field) as? StringReference)?.value()
} else {
null
}
} catch (_: Throwable) {
null
}
return if (detailMessage != null) {
"$className: $detailMessage"
} else {
className
}
}

private fun handleAccessWatchpointEvent(event: AccessWatchpointEvent) {
Expand All @@ -1737,7 +1761,8 @@ class JdiSession(
threadName = event.thread().name(),
location = "${safeSourceName(loc)}:${loc.lineNumber()}",
className = loc.declaringType().name(),
exceptionMessage = "Field ${event.field().name()} accessed"
exceptionMessage = "Field ${event.field().name()} accessed",
stacktrace = getFramesSafely(event.thread())
)
)
}
Expand All @@ -1752,7 +1777,8 @@ class JdiSession(
threadName = event.thread().name(),
location = "${safeSourceName(loc)}:${loc.lineNumber()}",
className = loc.declaringType().name(),
exceptionMessage = "Field ${event.field().name()} modified to ${event.valueToBe()}"
exceptionMessage = "Field ${event.field().name()} modified to ${event.valueToBe()}",
stacktrace = getFramesSafely(event.thread())
)
)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ enum class ErrorCode {
THREAD_NOT_SUSPENDED,
BREAKPOINT_NOT_FOUND,
BREAKPOINT_NOT_HIT,
INVALID_ARGUMENT,
EVALUATION_FAILED,
COMPILE_FAILED,
DEX_FAILED,
Expand Down
Loading
Loading