From d6eb1dfecd1fd7872e2bd5d26e15022b54e481a4 Mon Sep 17 00:00:00 2001 From: PatilShreyas Date: Mon, 26 Oct 2026 18:01:59 +0530 Subject: [PATCH] fix(core): enrich watchpoint/exception events with stacktrace and detailMessage, and validate watchpoint fields --- CHANGELOG.md | 1 + .../shreyaspatil/debroid/jdi/JdiSession.kt | 122 ++++---- .../dev/shreyaspatil/debroid/models/Models.kt | 1 + .../debroid/jdi/JdiSessionTest.kt | 269 +++++++++++++++++- skills/debroid-cli/SKILL.md | 4 +- 5 files changed, 338 insertions(+), 59 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1a8a230..917ac1d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 (`": "`). 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). diff --git a/core/src/main/kotlin/dev/shreyaspatil/debroid/jdi/JdiSession.kt b/core/src/main/kotlin/dev/shreyaspatil/debroid/jdi/JdiSession.kt index efb1a9d..7023751 100644 --- a/core/src/main/kotlin/dev/shreyaspatil/debroid/jdi/JdiSession.kt +++ b/core/src/main/kotlin/dev/shreyaspatil/debroid/jdi/JdiSession.kt @@ -73,7 +73,7 @@ class JdiSession( private val deferredBreakpoints = ConcurrentHashMap() private val deferredWatchpoints = ConcurrentHashMap>() private val deferredExceptionBreakpoints = ConcurrentHashMap>() - private val exceptionRequests = ConcurrentHashMap() + private val exceptionRequests = ConcurrentHashMap>() private val watchpointRequests = ConcurrentHashMap>() private val classPrepareRequest = AtomicReference(null) private val activeExclusions = AtomicReference>(emptySet()) @@ -324,7 +324,6 @@ class JdiSession( removeExceptionBreakpoint(existing.id) } - val erm = vm.eventRequestManager() val id = "ex_bp_${exceptionIdCounter.getAndIncrement()}" if (className != null) { @@ -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 } @@ -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()) { @@ -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 } @@ -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 @@ -547,7 +557,8 @@ class JdiSession( private fun listBreakpoints(): List = activeBreakpoints.values.toList() private fun listExceptionBreakpoints(): List { - 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, @@ -1546,7 +1557,6 @@ class JdiSession( } is ExceptionEvent -> { handleExceptionEvent(event) - true } is AccessWatchpointEvent -> { handleAccessWatchpointEvent(event) @@ -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() @@ -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) { @@ -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()) ) ) } @@ -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()) ) ) } diff --git a/core/src/main/kotlin/dev/shreyaspatil/debroid/models/Models.kt b/core/src/main/kotlin/dev/shreyaspatil/debroid/models/Models.kt index 018e654..c48bece 100644 --- a/core/src/main/kotlin/dev/shreyaspatil/debroid/models/Models.kt +++ b/core/src/main/kotlin/dev/shreyaspatil/debroid/models/Models.kt @@ -12,6 +12,7 @@ enum class ErrorCode { THREAD_NOT_SUSPENDED, BREAKPOINT_NOT_FOUND, BREAKPOINT_NOT_HIT, + INVALID_ARGUMENT, EVALUATION_FAILED, COMPILE_FAILED, DEX_FAILED, diff --git a/core/src/test/kotlin/dev/shreyaspatil/debroid/jdi/JdiSessionTest.kt b/core/src/test/kotlin/dev/shreyaspatil/debroid/jdi/JdiSessionTest.kt index ee25868..f86665d 100644 --- a/core/src/test/kotlin/dev/shreyaspatil/debroid/jdi/JdiSessionTest.kt +++ b/core/src/test/kotlin/dev/shreyaspatil/debroid/jdi/JdiSessionTest.kt @@ -2286,7 +2286,7 @@ class JdiSessionTest { } @Test - fun `client side exception filter ignores unrelated exceptions`() { + fun `client side exception filter ignores unrelated exceptions and returns false without calling vm resume`() { val req = mockk(relaxed = true) every { req.getProperty("className") } returns "com.example.ExpectedException" @@ -2303,19 +2303,270 @@ class JdiSessionTest { every { event.exception() } returns exceptionObj every { event.virtualMachine() } returns vm - // Use reflection to invoke private handleExceptionEvent method - val handleMethod = JdiSession::class.java.getDeclaredMethod( - "handleExceptionEvent", - com.sun.jdi.event.ExceptionEvent::class.java + val processMethod = JdiSession::class.java.getDeclaredMethod( + "processJdiEvent", + Event::class.java ) - handleMethod.isAccessible = true - handleMethod.invoke(session, event) + processMethod.isAccessible = true + val suspendsVm = processMethod.invoke(session, event) as Boolean - // Event should have been ignored and VM resumed - verify { vm.resume() } + // Filtered exception should return false so runEventListener calls eventSet.resume() + assertFalse(suspendsVm) + verify(exactly = 0) { vm.resume() } assertEquals(0, session.pollEvents("0").events.size) } + @Test + fun `handleExceptionEvent formats exceptionMessage with detailMessage when present`() { + val req = mockk(relaxed = true) + every { req.getProperty("className") } returns "java.lang.IllegalArgumentException" + + val detailField = mockk(relaxed = true) + val detailValue = mockk(relaxed = true) + every { detailValue.value() } returns "User ID must not be blank" + + val thrownClass = mockk(relaxed = true) + every { thrownClass.name() } returns "java.lang.IllegalArgumentException" + every { thrownClass.fieldByName("detailMessage") } returns detailField + + val exceptionObj = mockk(relaxed = true) + every { exceptionObj.referenceType() } returns thrownClass + every { exceptionObj.getValue(detailField) } returns detailValue + + val loc = mockk(relaxed = true) + val declaringType = mockk(relaxed = true) + every { declaringType.name() } returns "com.example.UserService" + every { loc.declaringType() } returns declaringType + every { loc.sourceName() } returns "UserService.kt" + every { loc.lineNumber() } returns 27 + + val frame = mockk(relaxed = true) + val method = mockk(relaxed = true) + every { method.name() } returns "validateUser" + every { loc.method() } returns method + every { frame.location() } returns loc + + val thread = mockk(relaxed = true) + every { thread.uniqueID() } returns 11L + every { thread.name() } returns "main" + every { thread.isSuspended } returns true + every { thread.frames() } returns listOf(frame) + + val event = mockk(relaxed = true) + every { event.request() } returns req + every { event.exception() } returns exceptionObj + every { event.location() } returns loc + every { event.thread() } returns thread + + val processMethod = JdiSession::class.java.getDeclaredMethod( + "processJdiEvent", + Event::class.java + ) + processMethod.isAccessible = true + val suspendsVm = processMethod.invoke(session, event) as Boolean + + assertTrue(suspendsVm) + val pollResult = session.pollEvents("0", withStacktrace = true) + assertEquals(1, pollResult.events.size) + val emitted = pollResult.events.first() + assertEquals(EventType.EXCEPTION_HIT, emitted.eventType) + assertEquals("java.lang.IllegalArgumentException: User ID must not be blank", emitted.exceptionMessage) + assertNotNull(emitted.stacktrace) + assertEquals(1, emitted.stacktrace?.size) + assertEquals("validateUser", emitted.stacktrace?.first()?.methodName) + } + + @Test + fun `handleExceptionEvent formats exceptionMessage as className only when detailMessage is null`() { + val detailField = mockk(relaxed = true) + val thrownClass = mockk(relaxed = true) + every { thrownClass.name() } returns "java.lang.IllegalStateException" + every { thrownClass.fieldByName("detailMessage") } returns detailField + + val exceptionObj = mockk(relaxed = true) + every { exceptionObj.referenceType() } returns thrownClass + every { exceptionObj.getValue(detailField) } returns null + + val loc = mockk(relaxed = true) + val declaringType = mockk(relaxed = true) + every { declaringType.name() } returns "com.example.UserService" + every { loc.declaringType() } returns declaringType + every { loc.sourceName() } returns "UserService.kt" + every { loc.lineNumber() } returns 30 + + val thread = mockk(relaxed = true) + every { thread.uniqueID() } returns 11L + every { thread.name() } returns "main" + every { thread.isSuspended } returns false + + val event = mockk(relaxed = true) + every { event.request() } returns null + every { event.exception() } returns exceptionObj + every { event.location() } returns loc + every { event.thread() } returns thread + + val processMethod = JdiSession::class.java.getDeclaredMethod( + "processJdiEvent", + Event::class.java + ) + processMethod.isAccessible = true + val suspendsVm = processMethod.invoke(session, event) as Boolean + + assertTrue(suspendsVm) + val pollResult = session.pollEvents("0") + assertEquals(1, pollResult.events.size) + assertEquals("java.lang.IllegalStateException", pollResult.events.first().exceptionMessage) + } + + @Test + fun `handleAccessWatchpointEvent and handleModificationWatchpointEvent populate stacktrace`() { + val loc = mockk(relaxed = true) + val declaringType = mockk(relaxed = true) + val method = mockk(relaxed = true) + every { declaringType.name() } returns "com.example.CartViewModel" + every { method.name() } returns "applyCoupon" + every { loc.declaringType() } returns declaringType + every { loc.method() } returns method + every { loc.sourceName() } returns "CartViewModel.kt" + every { loc.lineNumber() } returns 64 + + val frame = mockk(relaxed = true) + every { frame.location() } returns loc + every { frame.thisObject() } returns null + + val thread = mockk(relaxed = true) + every { thread.uniqueID() } returns 7L + every { thread.name() } returns "main" + every { thread.isSuspended } returns true + every { thread.frames() } returns listOf(frame) + + val field = mockk(relaxed = true) + every { field.name() } returns "couponInput" + + val accessEvent = mockk(relaxed = true) + every { accessEvent.location() } returns loc + every { accessEvent.thread() } returns thread + every { accessEvent.field() } returns field + + val newVal = mockk(relaxed = true) + every { newVal.toString() } returns "\"AUDIO20\"" + val modifyEvent = mockk(relaxed = true) + every { modifyEvent.location() } returns loc + every { modifyEvent.thread() } returns thread + every { modifyEvent.field() } returns field + every { modifyEvent.valueToBe() } returns newVal + + val processMethod = JdiSession::class.java.getDeclaredMethod( + "processJdiEvent", + Event::class.java + ) + processMethod.isAccessible = true + assertTrue(processMethod.invoke(session, accessEvent) as Boolean) + assertTrue(processMethod.invoke(session, modifyEvent) as Boolean) + + val withoutTrace = session.pollEvents("0", withStacktrace = false) + assertEquals(2, withoutTrace.events.size) + assertNull(withoutTrace.events[0].stacktrace) + assertNull(withoutTrace.events[1].stacktrace) + + val withTrace = session.pollEvents("0", withStacktrace = true) + assertEquals(2, withTrace.events.size) + + val accessPayload = withTrace.events[0] + assertEquals(EventType.WATCHPOINT_ACCESS_HIT, accessPayload.eventType) + assertEquals("CartViewModel.kt:64", accessPayload.location) + assertEquals("com.example.CartViewModel", accessPayload.className) + assertNotNull(accessPayload.stacktrace) + assertEquals(1, accessPayload.stacktrace?.size) + assertEquals("applyCoupon", accessPayload.stacktrace?.first()?.methodName) + + val modifyPayload = withTrace.events[1] + assertEquals(EventType.WATCHPOINT_MODIFY_HIT, modifyPayload.eventType) + assertEquals("CartViewModel.kt:64", modifyPayload.location) + assertEquals("com.example.CartViewModel", modifyPayload.className) + assertNotNull(modifyPayload.stacktrace) + assertEquals(1, modifyPayload.stacktrace?.size) + assertEquals("applyCoupon", modifyPayload.stacktrace?.first()?.methodName) + } + + @Test + fun `setWatchpoint throws INVALID_ARGUMENT when class is loaded and field does not exist`() { + val refType = mockk(relaxed = true) + every { refType.name() } returns "com.example.CartViewModel" + every { refType.fieldByName("nonExistentField") } returns null + every { vm.classesByName("com.example.CartViewModel") } returns listOf(refType) + + val ex = assertThrows { + session.setWatchpoint("com.example.CartViewModel", "nonExistentField") + } + + assertEquals(ErrorCode.INVALID_ARGUMENT, ex.code) + assertTrue(ex.message.contains("Field 'nonExistentField' not found in class 'com.example.CartViewModel'")) + assertTrue(session.getPoints().watchpoints.isEmpty()) + } + + @Test + fun `setWatchpoint binds across all loaded ReferenceTypes from multiple ClassLoaders`() { + val refType1 = mockk(relaxed = true) + val refType2 = mockk(relaxed = true) + val field1 = mockk(relaxed = true) + val field2 = mockk(relaxed = true) + every { field1.name() } returns "count" + every { field1.declaringType().name() } returns "com.example.Counter" + every { field2.name() } returns "count" + every { field2.declaringType().name() } returns "com.example.Counter" + every { refType1.fieldByName("count") } returns field1 + every { refType2.fieldByName("count") } returns field2 + every { vm.classesByName("com.example.Counter") } returns listOf(refType1, refType2) + + val accessReq1 = mockk(relaxed = true) + val accessReq2 = mockk(relaxed = true) + every { accessReq1.field() } returns field1 + every { accessReq2.field() } returns field2 + every { erm.createAccessWatchpointRequest(field1) } returns accessReq1 + every { erm.createAccessWatchpointRequest(field2) } returns accessReq2 + + val wpId = session.setWatchpoint("com.example.Counter", "count", access = true, modify = false) + + verify(exactly = 1) { erm.createAccessWatchpointRequest(field1) } + verify(exactly = 1) { erm.createAccessWatchpointRequest(field2) } + assertEquals(1, session.getPoints().watchpoints.size) + assertEquals(wpId, session.getPoints().watchpoints.first().id) + + assertTrue(session.removeWatchpoint(wpId)) + verify(exactly = 1) { erm.deleteEventRequest(accessReq1) } + verify(exactly = 1) { erm.deleteEventRequest(accessReq2) } + } + + @Test + fun `setExceptionBreakpoint binds across all loaded ReferenceTypes from multiple ClassLoaders`() { + val refType1 = mockk(relaxed = true) + val refType2 = mockk(relaxed = true) + every { vm.classesByName("com.example.CustomException") } returns listOf(refType1, refType2) + + val exReq1 = mockk(relaxed = true) + val exReq2 = mockk(relaxed = true) + every { exReq1.getProperty("className") } returns "com.example.CustomException" + every { exReq2.getProperty("className") } returns "com.example.CustomException" + every { erm.createExceptionRequest(refType1, true, true) } returns exReq1 + every { erm.createExceptionRequest(refType2, true, true) } returns exReq2 + + val exBpId = session.setExceptionBreakpoint( + "com.example.CustomException", + notifyCaught = true, + notifyUncaught = true + ) + + verify(exactly = 1) { erm.createExceptionRequest(refType1, true, true) } + verify(exactly = 1) { erm.createExceptionRequest(refType2, true, true) } + assertEquals(1, session.getPoints().exceptionBreakpoints.size) + assertEquals(exBpId, session.getPoints().exceptionBreakpoints.first().id) + + assertTrue(session.removeExceptionBreakpoint(exBpId)) + verify(exactly = 1) { erm.deleteEventRequest(exReq1) } + verify(exactly = 1) { erm.deleteEventRequest(exReq2) } + } + @Test fun `handleBreakpointEvent includes breakpointId from request property`() { val req = mockk(relaxed = true) diff --git a/skills/debroid-cli/SKILL.md b/skills/debroid-cli/SKILL.md index 592b6bf..7977cc6 100644 --- a/skills/debroid-cli/SKILL.md +++ b/skills/debroid-cli/SKILL.md @@ -69,7 +69,7 @@ Set your breakpoints or watchpoints *before* polling. **Save the returned IDs.** - Remove with `remove-catch-exception `. - **Watchpoint**: `debroid watch [--access] [--modify]` - Defaults: BOTH access and modify ON. Pass `--no-access` or `--no-modify` to disable either. - - Returns `{ "watchpointId": "wp_1" }`. + - Returns `{ "watchpointId": "wp_1" }` (or an `INVALID_ARGUMENT` error if the target class is already loaded and `` does not exist on it). - Remove with `remove-watch `. ### Step 4: Poll for Events @@ -81,7 +81,7 @@ debroid poll --with-stacktrace * **Subsequent Cursors:** Use the `nextCursor` value from the previous JSON response. * **Buffer Overflow & Stale Cursors:** Inspect `droppedEventsSinceLastPoll` (omitted from JSON when 0). If present and > 0, events were evicted from the buffer before they could be polled (e.g. due to high event volume or delayed polling). If an expected breakpoint or exception event is not present in `events`, it may have been among the dropped events. * **Waiting:** If `events` is empty, ask the user to trigger the action in the app, then poll again. -* **Event types you may see:** `BREAKPOINT_HIT` (includes `breakpointId`), `STEP_HIT`, `EXCEPTION_HIT`, `WATCHPOINT_ACCESS_HIT`, `WATCHPOINT_MODIFY_HIT`, `DISCONNECT`. +* **Event types you may see:** `BREAKPOINT_HIT` (includes `breakpointId`), `STEP_HIT`, `EXCEPTION_HIT` (`exceptionMessage` includes `: `), `WATCHPOINT_ACCESS_HIT` (includes `stacktrace` when `--with-stacktrace` is set), `WATCHPOINT_MODIFY_HIT` (includes `stacktrace` when `--with-stacktrace` is set), `DISCONNECT`. ### Step 5: Inspect the Pause State Once `poll` returns an event (e.g., `BREAKPOINT_HIT`), extract the `threadId`.