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
7 changes: 4 additions & 3 deletions docs/event-pipeline.md
Original file line number Diff line number Diff line change
Expand Up @@ -391,15 +391,16 @@ Every invocation the server makes in the target runs inside an `EvaluationGuard`
- **Count check.** T's suspend count must carry every hold the session knows: the anchor, every other covering context, and a manual hold. A count below that means a hold was undone outside the session — refused, nothing is resumed. A count above it is a set not routed yet, which routing exempts.
- **Exemptions.** T is resumed once out of every other context covering it and joins that context's `exempt`; a manual hold of T is resumed too. The borrowed count is then T's only one, and the invocation can run. A stop-all routed while leases are active exempts every leased thread other than its own event thread (`route`).
- **End (`unlease`).** T is suspended once back into each exempting context that still holds — one released meanwhile has compensated T already. A manual hold still present is put back. Last, the debt is resumed.
- **Frame tools first.** While a frame tool reads T (`reserve`), a lease of T waits until it is done — at most 2 s, then it is refused — or the invocation would run T under the tool ([threading-and-safety.md](threading-and-safety.md) § 5).
- **Transfer.** If the anchor is released while its invocation runs (`jdwp_resume`, `jdwp_reset`, `nextStop`'s release), the borrower keeps its share (see Releases). `jdwp_resume_thread` and a step on a leased thread are refused ("an evaluation is running on T").

**A lease spans invocations only, never frame reads.** JDI invalidates every `StackFrame` of a thread whenever the debugger resumes it, however many suspensions remain, and both an invocation and an exemption are such a resume. So the evaluator reads its frame first (the locals, `this`, the frame's class loader) and leases the thread only around its invocations; the fast-logpoint arming takes one lease per invocation group, not one across the whole arming. Frame tools that meet invalidated frames re-read them ([threading-and-safety.md](threading-and-safety.md) § 5).
**A lease spans invocations only, never frame reads.** JDI invalidates every `StackFrame` of a thread whenever the debugger resumes it, however many suspensions remain, and both an invocation and an exemption are such a resume. So the evaluator reads its frame first (the locals, `this`, the frame's class loader) and leases the thread only around its invocations; the fast-logpoint arming takes one lease per invocation group, not one across the whole arming. Frame tools that meet invalidated frames re-read them while a published stop or a manual hold keeps the thread at its stop ([threading-and-safety.md](threading-and-safety.md) § 5).

### The watchdog

A dispatcher invocation — a condition, a logpoint, a fast-logpoint arming — can block on a monitor owned by a thread that a set queued behind it keeps parked. The dispatcher never reaches that set, and no tool releases a set before it is processed. (A tool's own invocation has a bounded wait instead: `InvocationRunner`, [threading-and-safety.md](threading-and-safety.md) § 5.)

`SuspensionWatchdog` runs per session on the daemon thread `jdi-suspension-watchdog`; the listener starts it with the session, and it stops once the session is closed. Every 500 ms it samples the session's dispatcher leases (`dispatcherInvocations()`). The lease object is the sample's token, so samples never carry over to a later invocation on the same thread. An invocation watched for more than 3 s whose thread shows status MONITOR in two consecutive samples has its monitor's owner looked up, and the decision made, during one balanced suspension of the invoking thread: suspend → `currentContendedMonitor().owningThread()` → `unblock(token, owner, monitor)` → resume. Suspended, the invoking thread cannot take the monitor and move on before the decision. `unblock` re-checks everything under the session monitor and releases nothing unless:
`SuspensionWatchdog` runs per session on the daemon thread `jdi-suspension-watchdog`; the listener starts it with the session, and it stops once the session is closed. Every 500 ms it samples the session's dispatcher leases (`dispatcherInvocations()`). The lease object is the sample's token, so samples never carry over to a later invocation on the same thread. An invocation is aged from the sample that first sees its thread running; a sample that finds the thread suspended — host-side work between two invocations of the lease — starts the next invocation afresh (#47 § 2). A thread JDWP shows as blocked (MONITOR) or running is asked for the monitor it waits for, during a balanced suspension; running is asked too because JDWP shows a thread that invokes from inside its event's handling as running even while it waits for a monitor (#47 § 4). An invocation older than 3 s that waits for the same monitor in two consecutive samples has its monitor's owner looked up, and the decision made, during one balanced suspension of the invoking thread: suspend → `currentContendedMonitor().owningThread()` → `unblock(token, owner, monitor)` → resume. Suspended, the invoking thread cannot take the monitor and move on before the decision. `unblock` re-checks everything under the session monitor and releases nothing unless:

- the token is still a live dispatcher lease;
- the owner is not leased, not reserved by a frame tool, and not manually held;
Expand All @@ -416,7 +417,7 @@ A set with a fast breakpoint's stop event is never cancelled: its request carrie

Records: `EVALUATION_UNBLOCKED` (the owner, the evaluating thread, the sets) and `EVENT_CANCELLED`.

Limits: a spin or a native wait (status RUNNING) is not detected, nor is a dependency on a thread a published stop holds. `jdwp_resume` or `jdwp_reconnect` recover those.
Limits: a spin or a native wait (no contended monitor) is not detected, nor is a dependency on a thread a published stop holds. `jdwp_resume` or `jdwp_reconnect` recover those.

### Attach

Expand Down
7 changes: 4 additions & 3 deletions docs/testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ Live-JVM verification is done **manually** via the sandbox module, not automatic

`fastlogpoint/FastBreakpointLiveTest` does the same for fast breakpoints: stops in both modes and policies, condition errors, inspection and steps at the user's frame, evaluations that overlap a stop-all, a clear while a thread is stopped in the method (the restore waits for its release, also for two entries in one class), and watchers by fast-breakpoint ID. Same opt-in: `mvn -pl jdwp-mcp-server test -Djdwp.live=true -Dtest=FastBreakpointLiveTest`.

`fastlogpoint/SuspensionOwnershipLiveTest` drives suspension ownership (issue #45) against the same forked target (`LiveSession`, fixture `FastLogpointTarget`): its `lock-holder` thread holds `FastLogpointTarget.LOCK`, so an evaluation can be made to wait on a monitor whose owner a stop-all parks. It checks that the watchdog frees a dispatcher evaluation stuck behind a queued stop-all, and that `jdwp_resume` releases only the stop it holds. Same opt-in: `mvn -pl jdwp-mcp-server test -Djdwp.live=true -Dtest=SuspensionOwnershipLiveTest`.
`fastlogpoint/SuspensionOwnershipLiveTest` drives suspension ownership (issue #45) against the same forked target (`LiveSession`, fixture `FastLogpointTarget`): its `lock-holder` thread holds `FastLogpointTarget.LOCK`, so an evaluation can be made to wait on a monitor whose owner a stop-all parks. It checks that the watchdog frees a dispatcher evaluation stuck behind a queued stop-all, and that `jdwp_resume` releases only the stop it holds; and, for #47 § 4, `jdwp_reset` during an evaluation, a fast logpoint and a fast breakpoint armed by one hit, a stop-all released while its evaluation outlives the tool's wait, a queued thread stop the watchdog cancels followed by a new stop of that thread, and a disconnect during an evaluation that does not return — each ending with every count at 0 and the workers making progress. Same opt-in: `mvn -pl jdwp-mcp-server test -Djdwp.live=true -Dtest=SuspensionOwnershipLiveTest`.

## 2. Mocking JDI

Expand Down Expand Up @@ -68,13 +68,14 @@ Built on it:

- `SuspensionSessionTest` — publication, presentation, releases with compensation, steps, `jdwp_reset`, leases and admission, ownership transfer; every scenario ends on the suspend counts.
- `SuspensionSessionUnblockTest` — what `SuspensionSession.unblock` may release for the watchdog, how a cancelled set is settled, and what it never touches.
- `SuspensionWatchdogTest` — sampling driven by hand (`tick()`, an injected clock): the minimum age, two consecutive monitor samples, samples that belong to one invocation, a closed session stopping the watchdog.
- `SuspensionWatchdogTest` — sampling driven by hand (`tick()`, an injected clock): the minimum age, two consecutive samples on the same monitor, samples that belong to one invocation, a thread JDWP shows as running while it waits for a monitor, a closed session stopping the watchdog.
- `SuspensionRaceBarrierTest` — the races of #47 § 4, each held at its gap: the competing action runs inside the mocked JDI call that marks it, on the thread the dispatcher, the watchdog or a job would run it on. A job still compiling and a frame tool; a frame tool and the dispatcher's lease (which waits); the hold check and a frame tool's retry (only a published stop or a manual hold vouches for it); the watchdog's owner query and its decision; a job's compilation and a replaced stop.

Mockito only, around the same feature:

- `EvaluationGuardTest` — the depth marker, plus leases: a nested lease, a competing chain refused, a reconnect (`bind`) dropping the earlier connection's holds.
- `InvocationRunnerTest` — the bounded wait: the result or the rethrown failure, `[STILL_RUNNING]` then exactly one late record, a busy thread refused, no late record in a later connection, a job given up before it started never run, a monitor's owner named with a balanced suspension.
- `JDWPToolsFrameToolsFreshFramesTest` — the frame tools re-read invalidated frames only while a hold the session knows lasted, and refuse a thread an evaluation runs on.
- `JDWPToolsFrameToolsFreshFramesTest` — the frame tools re-read invalidated frames only while a hold only a tool ends lasted, and refuse a thread an evaluation runs on.

### `TestReflectionUtils`

Expand Down
4 changes: 2 additions & 2 deletions docs/threading-and-safety.md
Original file line number Diff line number Diff line change
Expand Up @@ -197,8 +197,8 @@ Invoked code can block — on a lock a parked thread holds, on a `join`. MCP too
`jdwp_get_stack`, `jdwp_get_locals`, `jdwp_set_local` and `jdwp_get_breakpoint_context` run their work through `JDWPTools.withFreshFrames(thread, …)`:

- **Refusal.** A thread an evaluation runs on is refused ("an evaluation is running on thread T — its frames are, or are about to be, the invoked code's"), before the suspended check: during an invocation the thread runs, and its frames belong to the invoked code. A job that outlived its tool's wait counts for its whole life (`InvocationRunner.isRunning`), also while it still compiles and holds no lease.
- **Reservation.** For the whole call the thread is reserved in the session (`reserve` / `unreserve`); the watchdog never releases a reserved thread.
- **Re-read.** A resume elsewhere — another stop-all's release, the fast-logpoint arming's VM-wide suspend — invalidates the frames the tool read, though the thread stays suspended. On `InvalidStackFrameException` the tool reads fresh frames and rebuilds its whole reply, at most three times (`FRAME_ATTEMPTS`) — but only while a hold the session knows lasted across the attempt. `SuspensionSession.holdsOn(T)` (the ids of the contexts holding T, a lease's exemption aside, plus the manual hold) is taken before the attempt and after its failure, and the two must intersect. Context ids are never reused, and a manual hold cannot be dropped and re-taken during a serialized tool call, so a common hold proves T never ran past its stop and the fresh frames are the same activations. Otherwise T may have run on and stopped elsewhere; the tool answers that it was resumed meanwhile, and `jdwp_set_local` adds that nothing was written.
- **Reservation.** For the whole call the thread is reserved in the session (`reserve` / `unreserve`); the watchdog never releases a reserved thread, and a lease of it waits until the call ends (`awaitUnreserved`, at most `RESERVATION_WAIT_MS` = 2 s, then the lease is refused). Only the dispatcher can lease the thread meanwhile — for a set of the thread's own that it processes, its condition say; tools run one at a time, and a job that outlived its tool's wait refuses the frame tool already. Without the wait the invocation would run the thread under the tool, and the frames it read next would be the invoked code's (#47 § 4).
- **Re-read.** A resume elsewhere — another stop-all's release, the fast-logpoint arming's VM-wide suspend — invalidates the frames the tool read, though the thread stays suspended. On `InvalidStackFrameException` the tool reads fresh frames and rebuilds its whole reply, at most three times (`FRAME_ATTEMPTS`) — but only while a hold only a tool ends lasted across the attempt. `SuspensionSession.userHoldsOn(T)` (the ids of the published stops holding T, plus the manual hold) is taken before the attempt and after its failure, and the two must intersect. Context ids are never reused, the dispatcher never releases a published stop, the watchdog never touches one or a manual hold, and tools run one at a time, so a common hold proves T never ran past its stop — and still holds it when the retry reads — and the fresh frames are the same activations. A set still queued or processed does not count: the dispatcher may release it between the check and the retry's read, and T run on to another stop, whose frames no invalidation would expose (#47 § 4). Otherwise T may have run on and stopped elsewhere; the tool answers that it was resumed meanwhile, and `jdwp_set_local` adds that nothing was written.
- **Writes.** `jdwp_set_local` is retried only on that exception, which JDI raises before sending the write or when the target rejects the frame — either way the write did not land.

Without a session (no connection yet), nothing is reserved or retried.
Expand Down
16 changes: 9 additions & 7 deletions jdwp-mcp-server/src/main/java/one/edee/mcp/jdwp/JDWPTools.java
Original file line number Diff line number Diff line change
Expand Up @@ -1255,11 +1255,13 @@ private ReferenceType forceLoad(String className) throws Exception {
* stop's release, the arming's VM-wide suspend: JDI invalidates every frame of a resumed thread
* though it stays suspended (issue #45 A12). Each attempt rebuilds its whole reply.
* <p>
* It runs again only while a hold the session knows lasted across the attempt, so the fresh
* frames are the same activations; otherwise the thread may have run and stopped elsewhere, and
* the tool answers with {@code ifRunOn}. A write is retried only on this exception, which JDI
* raises before sending it or when the target rejects the frame — either way it did not land.
* Meanwhile the thread is reserved: the watchdog does not release it (A2).
* It runs again only while a hold only a tool ends — a published stop, a manual hold — lasted
* across the attempt: nothing releases it before the next read, so the fresh frames are the same
* activations. A set the dispatcher still processes may be released between the check and that
* read (#47 § 4). Otherwise the thread may have run and stopped elsewhere, and the tool answers
* with {@code ifRunOn}. A write is retried only on this exception, which JDI raises before sending
* it or when the target rejects the frame — either way it did not land. Meanwhile the thread is
* reserved: the watchdog does not release it (A2), and a lease of it waits until the tool is done.
*/
private String withFreshFrames(ThreadReference thread, String ifRunOn, Callable<String> work) throws Exception {
final SuspensionSession session = breakpointTracker.suspensionSession();
Expand All @@ -1269,7 +1271,7 @@ private String withFreshFrames(ThreadReference thread, String ifRunOn, Callable<
}
session.reserve(thread);
try {
Set<Long> holds = session.holdsOn(thread);
Set<Long> holds = session.userHoldsOn(thread);
for (int attempt = 1; ; attempt++) {
final String evaluating = evaluationRunningOn(thread);
if (evaluating != null) {
Expand All @@ -1281,7 +1283,7 @@ private String withFreshFrames(ThreadReference thread, String ifRunOn, Callable<
if (attempt == FRAME_ATTEMPTS) {
throw invalidated;
}
final Set<Long> now = session.holdsOn(thread);
final Set<Long> now = session.userHoldsOn(thread);
if (Collections.disjoint(holds, now)) {
return String.format("Error: thread %s was resumed while its frames were being read, and may "
+ "have run on since — %s", thread.name(), ifRunOn);
Expand Down
Loading
Loading