Skip to content

feat: jdwp_set_fast_breakpoint and suspension ownership (#42, #45) - #46

Merged
novoj merged 20 commits into
mainfrom
feat/fast-breakpoint-42
Oct 30, 2026
Merged

novoj merged 20 commits into
mainfrom
feat/fast-breakpoint-42

Conversation

@novoj

@novoj novoj commented Oct 29, 2026 •

Copy link
Copy Markdown
Contributor

Closes #42. Closes #45, and with it #43 and #44.

Fast breakpoints (#42)

jdwp_set_fast_breakpoint evaluates a breakpoint's condition inside the target JVM, so hits that don't meet it never involve the debugger.

  • First hit. It is an ordinary debugger stop that evaluates the condition. If the condition holds, that hit is the stop. Otherwise a probe class is compiled and defined in the declaring class's loader, and the method is redefined to call it.
  • Later hits. The probe evaluates the condition. When it holds, the probe calls an empty stop() method, which carries an ordinary JDI breakpoint with the user's suspend policy. The user's method holds no breakpoint and stays JIT-compiled.
  • Which hits stop. Two gates with server-written generations decide: once per generation (re-arm by calling the tool again) or every hit. A condition that fails stops once.
  • Consent. Like a fast logpoint, the tool rewrites application bytecode, so nothing is armed without userApproved=true. Without it, the tool returns a risk briefing.
  • At a fast stop. The probe frames are hidden (FrameView): every frame index the tools take or print counts from the user's frame. Steps never enter probe classes.
  • The redefinition rule. Arming and restoring never redefine a method in which a stopped thread has a frame, since that would cost the frame its local variables. Arming waits for the thread's release. A clear switches the probe off at once and restores the class after the release; jdwp_reset restores at once. This applies to fast logpoints too.
  • Watchers and dependencies. Watchers attach to fast breakpoints. The full-stack watcher scan covers every breakpoint at a line. Breakpoint dependencies refuse fast IDs, because the target decides those hits.
  • Leftovers. A fast breakpoint a dead server left behind is listed by the next session, like a leftover fast logpoint.

Test flight #10 ("The Oversold House") is a highly concurrent target for fast logpoints and fast breakpoints.

Suspension ownership (#45; folds in #43 and #44)

  • Contexts. Each routed event set is a context (QUEUED → PROCESSING → HELD | RELEASED), and only that context releases its suspension.
    • jdwp_resume releases only the stops a tool has shown, plus jdwp_suspend_thread holds.
    • jdwp_resume_until_event shows an unseen stop first, without resuming anything.
    • Steps are atomic and use the EVENT_THREAD policy.
    • jdwp_reset starts a new generation.
  • Leases. A thread the debugger invokes on is leased and exempt from other stops. A suspend-all event no longer freezes an evaluation running on another thread.
  • Bounded invocations. A tool's invocation runs on a worker with a 20 s bound. After that the tool answers [STILL_RUNNING], and a late result is recorded as EVALUATION_COMPLETED_LATE. Releasing a stop keeps the share a running evaluation still needs.
  • Frame tools. They refuse a thread an evaluation runs on, and re-read invalidated frames only while a known hold lasted.
  • Watchdog. It frees a dispatcher evaluation stuck on a monitor that a queued stop's thread holds.
  • Pinning. Every object the debugger creates in the target is pinned against GC from the moment it exists (PinnedMirrors).

The developer docs cover both in docs/breakpoints.md § 5c–§ 5d and docs/event-pipeline.md § 14. The README, CLAUDE.md and the java-debug skill describe the new tool (50 tools in all).

Commits

Area Commits
Test flight #10 212fce1
Fast breakpoint: probe, service, tool, FrameView c1fc642, 7cb2c37, d004360, dfa02d8
Pinning 862fffd, 3e9f15a
Suspension ownership de4a72a, 56d9493, ce65567, 479ea94, a5c531e, 03fa71c, a4439b9
Redefinition rule, watchers, dependencies 2d2c686, ceb3562
Briefing, tool texts, docs 1ea5bd6, 901c1aa
Code review fixes ce89b73 (#42), 47dc5e5 (#45)

Review

Tests

  • Full suite: 1344 tests, 0 failures, 36 skipped (the opt-in live tests).
  • Live tests (-Djdwp.live=true, with the JaCoCo agent): FastBreakpointLiveTest 14/14, FastLogpointLiveTest 17/17 and SuspensionOwnershipLiveTest 2/2.
  • Listener tests on one core: all green.
  • Eight live loops on two cores: all green.
  • Not done: test flight fix: emit eval wrapper into target's package when this is non-public (#7) #10 has not been flown live with the MCP server.

novoj added 20 commits October 29, 2026 11:48
A concurrent check-then-act race for in-target probes (issue #42). Eight
box-office windows sell 300 performances of 200,000 seats. About six seats
per performance are sold twice, so the test fails on every run. It takes
about 3-4 s and prints the elapsed time and ns per attempt, so it doubles as
the measurement workload for fast logpoints and fast breakpoints;
-Dboxoffice.seats and -Dboxoffice.performances scale it down for slow tools.

The READMEs list the flight (now 10 flights, 30 stars), and the sandbox
house rules add the consent rule for the fast probe tools.
FastBreakpointProbeSource generates the probe class of jdwp_set_fast_breakpoint.
hit() evaluates the condition inside the target JVM and calls the empty
stop(kind, epoch, error) only when the condition holds and its gate lets
it through. The server's JDI breakpoint sits on stop(), so the instrumented
method and hit() stay eligible for JIT compilation and inlining.

- The true-hit gate (used in once mode) and the error gate (the first
  failure of each generation stops) have separate generations, EPOCH and
  ERROR_EPOCH, which only the server advances. A claim never moves a gate
  backwards, so an evaluation that started before a re-arm cannot claim
  the new generation.
- Condition failures are recorded in the ring and passed to stop() as
  text. The error path never throws into the application.
- TRUE_HITS, ERRORS and STOPS count the outcomes, and MCP_KIND marks the
  probe kind.

FastLogpointProbeSource now shares its class start, common fields and ring
recorder. The generated fast-logpoint source is unchanged apart from the
extracted __mcpDescribe helper. JdiExpressionEvaluator gains
compileFastBreakpointProbe on the shared probe-compilation path.
FastLogpointService now owns fast breakpoints alongside fast logpoints.
They share the arming, the per-class instrumentation and the restore.
Each entry carries a kind, its own arming suspend policy, the stop-once
mode, server-side gate generations and a service-owned stop request on
the probe's stop() method.

- The arming hit evaluates the condition through the debugger. A hit
  whose condition holds or fails while its gate is open is published as
  a stop on the spot and installs nothing: redefining the method would
  leave the stopped frame in an obsolete method without locals. Any
  other arming hit installs the probe. The gates and the stop request
  are set up before the redefinition, and the probe is enabled last.
- A probe stop is published (tracker snapshot, FAST_BREAKPOINT history
  entry, next-event latch) unless it is stale: the breakpoint was
  cleared, or its gate was re-armed since. A stop raised inside an MCP
  evaluation is suppressed and re-opens only the gate it claimed.
- Clearing deletes the stop request and retires it, so an in-flight
  stop is resumed. Leftover discovery recognizes both probe kinds.

JdiEventListener routes stop events to the service before the regular
breakpoint handler, keeps an arming hit published as a stop suspended,
and hands suppressed stops to the service. The service monitor is never
held across a method invocation in the target.

Covered by routing unit tests only. The next commit adds the tool and
a live test that exercises these paths.
The tool sets a conditional breakpoint whose condition runs inside the
target. Without userApproved=true it returns FastBreakpointRiskBriefing
and arms nothing. Invalid arguments (a blank condition, a policy other
than all/thread) are refused before the briefing.

Calling it again with the same arguments re-arms an owned fast
breakpoint: both gates move to their next generation. A fast breakpoint
already set at the line with other arguments is refused with "clear it
first". The reply warns when tracked breakpoints in the same method keep
it interpreted.

Fast breakpoints are listed in their own jdwp_overview / jdwp_clear
section (type fast_breakpoint), with mode, policy and the probe's
counters. A leftover shows neither its policy nor whether it is spent.

A fast breakpoint's stop is reported as "fast_breakpoint=N, condition
true" (or "condition failed: ...") by jdwp_resume_until_event,
jdwp_get_current_thread and jdwp_get_breakpoint_context.
jdwp_resume_until_event gives the line in the user's code, not the
probe's stop().

Host and leftover refusals now name the right kind and fallback tool.

Arming against a resume, found by the first live test:
- An arming hit holds its thread through the event while the dispatcher
  invokes on it. "Set it, then jdwp_resume_until_event" called
  vm.resume() in that window and failed the arm, either with
  IncompatibleThreadStateException or with a misleading "no local JDK"
  from classpath discovery.
- jdwp_resume and jdwp_resume_until_event now wait up to 30 s for an
  arming in flight. jdwp_resume_until_event waits before its
  pending-fire check, so a stop published by the arming hit is
  reported, not resumed.
- A hit lost to a resume before anything was installed is re-armed. It
  records no condition error and no stop.
- handleArmingHit returns an ArmingOutcome. For SPENT the dispatcher
  does not resume the set again: under suspendPolicy=all on a hot line,
  the fresh arming hit has already parked every thread, and a second
  vm.resume() would release it too.

Tests:
- FastBreakpointLiveTest covers the consent gate, once mode parking
  every thread with re-arm, every-hit with suspendPolicy=thread, a
  failing condition that stops once and then installs, and a true first
  hit that leaves the class untouched.
- LiveSession is extracted from FastLogpointLiveTest so both live tests
  share it.
- Unit tests cover the tool surface, the lost-hit path and the resume
  wait.
Where a fast breakpoint stops, the physical stack starts with the
probe's stop() and hit() (and __mcpError() for a failed condition).

FrameView numbers a suspended thread's stack without the leading frames
of probe classes, of this session or an earlier one. Logical frame 0 is
the user's frame that called hit(). Probe frames deeper in the stack,
for example under a breakpoint inside a method the condition calls,
stay visible. The view keeps only the hidden count, never a StackFrame,
so it stays valid across invocations, and it classifies the prefix in
one JDWP fetch.

Every frameIndex and printed #N is now logical: jdwp_get_stack (with a
"frames hidden" note), jdwp_get_locals, jdwp_evaluate_expression,
jdwp_assert_expression, jdwp_set_local, jdwp_get_breakpoint_context,
jdwp_get_current_thread, jdwp_resume_until_event and both watcher
paths.

Steps exclude every probe class, the listed ones and the one the thread
stands in, before the count filter. A completed step inside an
excluded class is restarted by the agent and does not use up the
count. From a fast stop every kind of step therefore lands on the stop
line, before its code, and the step reply says so.

jdwp_set_local explains the JDK 21 limit on virtual threads (only the
top frame's locals can change) instead of a bare OpaqueFrameException.

jdwp_reset now reports "In-target probes cleared" and "Earlier sessions'
probes", since both counts include fast breakpoints.

Tests:
- FrameViewTest covers the hidden prefix, deeper probe frames, a
  thread suspended inside hit() and an all-probe stack.
- A tool test checks that a step from a fast stop adds the exclusions
  before the count filter.
- A live case covers stack, locals, evaluation and set_local at the
  user's frame, and a step that lands on the stop line. The test waits
  for the step to land before jdwp_resume_until_event: until #45, that
  tool's blind vm.resume() can use up the suspension of a step event
  still in flight.
An object created through JDI is referenced by nothing in the target until
it is pinned. RemoteCodeExecutor pinned the bytecode array and the class-name
string only a few JDWP round trips after creating them, so a GC in between
collected one and defineClass failed with ObjectCollectedException — seen as
a spurious "condition failed" on a fast breakpoint's arming hit.

Each mirror is now pinned right after its creation, and created again when a
GC collected it first (up to three attempts). The Class.forName argument is
pinned too. Every pin is released in a finally block.
A SuspensionSession, one per connection, owns what the debugger suspended.
Each routed event set becomes a context (QUEUED -> PROCESSING -> HELD or
RELEASED). The handlers' stops are candidates, published once the whole set
is processed. Only the owning context releases its suspension, so a tool can
no longer spend a suspension that is still being processed or was never
shown.

- jdwp_resume releases only the stops already reported and the manual holds.
  A stop nobody has seen stays held.
- jdwp_resume_until_event presents the oldest unseen stop first, and resumes
  nothing. Otherwise it releases the seen stops and waits for the next one.
  It reports a reset or a closed session, and names what it released.
- jdwp_suspend_thread / jdwp_resume_thread go through the session.
  resume_thread refuses a thread whose suspension is still queued, processing
  or unreported.
- A step is installed atomically under the session, with the EVENT_THREAD
  policy, and releases the stop it steps from. Other threads stay parked
  until jdwp_resume. A new stop on the thread interrupts a pending step.
- jdwp_reset deletes step requests, then releases every hold under a new
  generation. Events of stale requests are released without a stop.
- jdwp_connect / jdwp_wait_for_attach wait up to 500 ms for VM_START and
  present it as a stop (via=vm_start).
- jdwp_set_breakpoint refuses suspendPolicy=none with a condition.
- An untracked breakpoint is a stop with no ID (via=untracked_breakpoint).
- A set with the ALL policy re-suspends the threads released in between
  before it resumes, so it never releases another stop's hold.

The arming stopgaps of the fast breakpoint commit are removed
(awaitArmingIdle, ArmingOutcome.SPENT). A resumed arming thread now fails
the arm.

Arming requests are enabled while the VM is suspended. JDI learns a
request's ID only from the enable reply, and an event raised before that
reply is read is dropped as unknown. On a hot line that was the count-filter
request's only event, and the fast logpoint or breakpoint never armed.

Not yet in this commit:
- leases, admission, exemptions, the invocation watchdog and the 20 s
  worker-invocation timeout;
- a suspend-all event raised while the dispatcher invokes on another thread
  still freezes that invocation;
- only step requests carry the generation stamp;
- clearing a held breakpoint does not mention its held stop.
An object created through JDI is referenced by nothing in the target until it
is pinned, so a GC in between collects it and the call it was made for fails
with ObjectCollectedException. The previous commit closed this window in
RemoteCodeExecutor only. Four more sites created a string and used it
unpinned, the riskiest being the field-watchpoint $fieldName/$mode bindings,
which stay unpinned across a whole expression compile.

- PinnedMirrors (AutoCloseable) pins each object right after its creation,
  creates it again when a GC collected it first (three attempts), and
  releases every pin on close. RemoteCodeExecutor now uses it instead of its
  private copy.
- Pinned now: the field-watchpoint bindings, the Class.forName argument of
  the force-load in JDIConnectionService, and the System.getProperty argument
  in ClasspathDiscoverer and JdkDiscoveryService.
- The field-watchpoint bindings are built only when a condition or a logpoint
  expression can use them. A plain field breakpoint no longer creates two
  strings in the target on every hit.
An invocation borrows one suspension of its thread. When another stop also
suspends that thread, the borrowed count leaves it suspended and the
invocation never returns. The common case is a suspend-all of another thread
arriving while the dispatcher evaluates a condition, a logpoint or an arming
hit. The dispatcher then hangs, and every later stop with it.

- EvaluationGuard.lease(thread) replaces enter/exit. A thread's outermost lease
  is taken in the SuspensionSession. The session admits the invocation and
  takes the thread out of every other context that suspends it (one resume
  each); a stop-all routed during a lease does the same. When the lease ends,
  the thread goes back into each context that still holds. A context released
  meanwhile re-suspends it before its own resume, so every count nets out.
- Admission. The dispatcher may invoke on the thread of the set it processes.
  Any other invocation needs the thread stopped at an event of its own that a
  tool has shown. Otherwise it is refused with the reason (parked by another
  thread's stop, held by jdwp_suspend_thread, a stop not yet seen, VM start)
  instead of failing with IncompatibleThreadStateException or hanging.
- An exemption at lease entry requires the thread's suspend count to show the
  other context's suspension. A hold undone behind the session's back cannot
  cost the lease its own suspension.
- A lease spans invocations only, never frame reads. JDI invalidates every
  StackFrame of a thread the debugger resumes, also when the resume leaves it
  suspended. The evaluator reads the frame before leasing, and the arming
  leases each invocation group separately.

Fixes found by two-core live loops:
- The evaluator reads its frame again when a resume elsewhere invalidated it
  before the invocation (another set's release, the arming's VM-wide suspend).
  The define-time package fallback now uses a fresh frame too.
- Arming retries a step whose frame was invalidated, as it already did for
  one whose object was collected.
- A cleared probe's ring is nulled after its class is restored, not before.
  A hit that passed the kill switch just before it was set still wrote its
  entry.

Tests:
- FakeSuspendModel: the proposal's three exemption orderings, the admission
  refusals, and the lease facade.
- FastBreakpointLiveTest reproduces the frozen dispatcher; it fails without
  the route-time exemption.
- FastLogpointLiveTest releases its parked thread through the session
  instead of a raw resume.

Not yet: the worker invocation thread with its timeout, inspection
reservations, and the dispatcher watchdog.
… every hold (#45)

Fixes found by an adversarial review of the leases (56d9493):

- The guard counted leases per target thread, shared by every Java thread. A
  worker evaluating on a thread the dispatcher already held re-entered at
  depth 2 and skipped admission. If the first caller ended first, the session
  lease vanished while the other invocation still ran. The counter also
  outlived a reconnect. Now a hold belongs to one invocation chain (the Java
  thread running it) of one session. Only that chain re-enters, and any other
  caller is refused ("an evaluation on T is already running"). A hold is
  released by token identity, and binding a new session drops the earlier
  connection's holds.
- Admission now compares the thread's suspend count with the holds the
  session knows on it: its stop, every other covering stop, and a manual hold.
  A lower count means a hold was undone outside the session. The lease is
  refused and nothing is resumed, where it used to skip exemptions on a
  count > 1 guess. A higher count is a set not routed yet, which routing
  exempts.
- jdwp_suspend_thread can cross a breakpoint and leave a manual hold on top of
  the stop; the invocation then hung. That manual hold is now exempted for the
  lease and put back at its end. A manual release during the lease does not
  resume the thread twice.

Tests:
- FakeSuspendModel: one invocation per thread, and an invocation completes
  only while its thread runs.
- New cases: competing chains, a reconnect with an equal thread ID, a stale
  hold, the manual-hold race, and a manual release during a lease.
- The live case now asserts that the stop-all landed during the evaluation
  (routeExemptions in the session diagnostics).
… one's share (#45)

Invoked code can block on a lock that a parked thread holds. MCP tool calls
are serialized, so a tool blocked that way left no tool to release the thread
with.

- InvocationRunner runs a tool's invoking work on a cached daemon pool and
  waits at most 20 s. The job covers the whole work: classpath discovery, the
  frame read and the evaluation. It is used by jdwp_evaluate_expression,
  jdwp_assert_expression, jdwp_evaluate_watchers, jdwp_to_string and the six
  forceLoad paths; the dispatcher never uses it.
  - Work that outlives the wait keeps running. The tool answers
    [STILL_RUNNING], naming what blocks the thread: its status, and for a
    monitor wait the owner of that monitor (a brief, balanced suspend).
  - Its result is recorded later as EVALUATION_COMPLETED_LATE, only in the
    session it started in.
  - One compare-and-set settles the timeout against the completion, so a job
    yields either its result or exactly one late record.
  - A thread with a job still running refuses another job before anything is
    read from it. A job abandoned before it started never runs.
- Releasing the stop an invocation borrows from (jdwp_resume, jdwp_reset)
  used to resume the thread at count 0 mid-invocation, a no-op. The
  invocation's restore then left the thread suspended with no owner. Now the
  borrower's share moves to its lease (a stop-all compensates it before its
  resume, a stop of that thread alone skips its resume), and the lease
  resumes it when it ends. Every other thread is released at once.
- jdwp_resume_thread and a step on a thread an evaluation is running on are
  refused.

Tests:
- InvocationRunnerTest: result, failure, late record, busy thread, dropped
  connection, abandoned job, blocker diagnosis.
- SuspensionSession: the transfer rows (thread-only and stop-all stops,
  released mid-invocation and between invocations, reset, another stop still
  holding), and the refusals.
- FastBreakpointLiveTest: a 25 s evaluation answers [STILL_RUNNING] after
  20 s. jdwp_resume releases its stop meanwhile, the late result arrives, and
  the thread then runs with no suspension left.
…invalidated frames (#45)

jdwp_get_stack, jdwp_get_locals, jdwp_set_local and jdwp_get_breakpoint_context
refuse a thread an evaluation is running on, before checking that it is
suspended: during an invocation it runs, and its frames are the invoked code's.

JDI invalidates every frame of a thread on any resume of it (another stop-all's
release, the arming's VM-wide suspend) though the thread stays suspended. The
tools now read fresh frames and rebuild the reply, at most three times, but only
while a hold the session knows lasted across the attempt: SuspensionSession.holdsOn
(the contexts holding the thread, a lease's exemption aside, plus the manual
hold) taken before the attempt and after its failure must intersect. Otherwise
the thread may have run on and stopped elsewhere; the tool says so, and
jdwp_set_local that nothing was written. A write is retried only on
InvalidStackFrameException, which JDI raises before sending it or when the
target rejects the frame.
…ueued stop holds (#45)

A condition, logpoint or arming evaluation on the dispatcher can block on a
monitor whose owner a set still queued behind it keeps parked; the dispatcher
never reaches that set, and no tool releases a set before it is processed.

SuspensionWatchdog samples the dispatcher's invocations every 500 ms. One older
than 3 s, blocked on a monitor in two consecutive samples of the same lease, has
its monitor's owner asked for (a balanced suspend of the invoking thread), and
SuspensionSession.unblock releases that owner - re-checked under the session
monitor - only when it is held solely by queued or processing sets and its count
is exactly theirs: never a thread under a published stop, a manual hold, a
leased or reserved thread, or a hold the session does not know.

A bystander of a stop-all joins its released set and the stop, if published, is
reported as partial. The event thread of a queued set cancels the set: a
thread-only set is settled at once (its resume is the thread's), so its later
processing cannot spend the count of a stop the thread reaches meanwhile; a
stop-all keeps its other threads until processed and is released with
compensation. A cancelled set never publishes; the dispatcher only re-arms an
arming hit, deletes a step request and handles a class prepare. A set with a
fast breakpoint's stop is never cancelled (KEEPS_THREAD_PROPERTY). Frame tools
reserve their thread for the watchdog. EVALUATION_UNBLOCKED and EVENT_CANCELLED
record what was done.
…the release rules (#45)

event-pipeline.md drops the "later set" resume rule and gains section 14,
suspension ownership: contexts and their states, presentation, releases with
compensation, steps, reset, leases and admission, the watchdog, the attach
handshake. Sections 1-5, 8 and 9 now describe candidates and finish(),
buffered history, suppression at routing, and nextStop(). threading-and-safety,
architecture, lifecycle, expression-evaluation, diagnostics, testing, index,
CLAUDE.md and the README follow; the EventHistory and JdiEventListener Javadoc
no longer describe the removed rule.

SuspensionOwnershipLiveTest gains the release rule against a real JVM: with a
stop held and a stop-all queued behind a dispatcher evaluation, jdwp_resume
releases only the held stop, the queued set keeps its threads, and its stop is
shown later with its thread stopped.
…val wait for its release (#42)

Redefining a method turns every frame in it obsolete, and an obsolete frame
loses its locals. Fast logpoints and fast breakpoints now honour the
redefinition rule both when they arm and when they are cleared.

- SuspensionSession.heldThreads names the threads the user may see stopped:
  a context that is HELD, or QUEUED/PROCESSING in the current generation
  with a set that can stop; a jdwp_suspend_thread hold; a step in flight; and
  a suspension no set accounts for (a set raised but not routed yet). Under
  the VM pause, the rule waits up to 200 ms for the listener to route such
  sets, nearly always logpoint hits.
- The listener judges at routing whether a set can stop (JdiEventListener.
  mayStop). Logpoints of every kind, fast-logpoint arming hits and class
  prepares never stop, so a hot jdwp_set_logpoint no longer starves arming.
- Arming runs a check without a pause before compiling, then the
  authoritative check under vm.suspend() spanning check -> redefine. A
  deferred hit re-arms and records _ARMING_DEFERRED once per hold. An arming
  hit on an already-obsolete frame is a lost hit (re-armed), not a failure.
- A clear blocked by the rule disables the entry at once (kill switch, stop
  request gone, ring drained) and keeps it as REMOVAL_PENDING. The class is
  restored at jdwp_resume, jdwp_resume_until_event, jdwp_resume_thread,
  jdwp_overview, or the service's next register, clear or drain.
- jdwp_reset restores unconditionally, like disconnect and shutdown: it
  releases every stop right after, so no frame anyone sees turns obsolete.
- Cost: about 50-100 ms of VM pause per install with roughly 200 threads.

Found by the two-core gate loops:
- BreakpointTracker forgot a cleared request before deleting it, and the
  listener could read a cleared logpoint's metadata after it was dropped.
  Either way a hit already queued stopped its thread as an untracked or plain
  breakpoint, and that stop now blocks every redefinition of its method. The
  tracker deletes first, and the listener drops a hit whose breakpoint is
  gone and whose request is deleted.
- The live tests enable their one-shot park requests under vm.suspend(), as
  the arming requests are, and assert an immediate restore by its own reply.
  FastLogpointLiveTest Order 9 now proves that arming waits for a thread
  parked in the method.
…oint at a line, and dependencies that refuse fast IDs (#42)

- jdwp_attach_watcher accepts a fast breakpoint this session set that can still stop (pending or armed);
  a fast logpoint, a leftover and a failed or disabled fast breakpoint are refused with the reason.
- The full-stack watcher scan evaluates the watchers of every breakpoint at a frame's line, ordinary
  and fast: getBreakpointLocationMap maps a line to all its active IDs (it kept one, so a breakpoint
  and a logpoint on one line lost a watcher), merged with the service's fast breakpoints.
- Clearing a fast breakpoint removes its watchers, in jdwp_clear_breakpoint and jdwp_clear, even
  while its restore waits; a reconnect drops the watchers of the fast breakpoints it disarms.
- A breakpoint dependency refuses a fast breakpoint or fast logpoint as trigger or dependent.
- FastLogpointService deletes an arming request before forgetting it, as the tracker does: a hit
  still queued can no longer stop as an untracked breakpoint.
- Live: watchers at a fast stop in both scopes, two pending removals in one class restored together,
  and a restore refused after a foreign redefinition (the entry stays DISABLED, clear retries).
…ar while stopped restores (#42)

- The risk briefing's point 6 said that clearing the breakpoint while a thread is stopped in the
  method restores the class at once, costing the stopped frame its locals. Since the redefinition
  rule the clear switches the breakpoint off at once and restores the class after that thread is
  released; jdwp_reset restores at once and releases every stop.
- jdwp_clear_breakpoint, jdwp_reset and jdwp_restore_fast_logpoints describe fast breakpoints too;
  the clear also says it removes the breakpoint's watchers.
- EventHistory lists the _ARMING_DEFERRED and _REMOVAL_DEFERRED types of both kinds.
…and java-debug skill (#42)

- breakpoints.md: § 5c (fast breakpoints: probe, gates, arming, the stop, logical frames, watchers
  and dependencies, leftovers) and § 5d (never redefine under a stopped frame: held threads, the
  check, deferred arming and removal, cost); the clear routing, § 5a's disarm and § 5b's leftovers
  follow.
- event-pipeline.md and testing.md: the § 5c/§ 5d references, the deferred event types and
  FastBreakpointLiveTest.
- CLAUDE.md: 50 tools; fast breakpoints, FrameView and the redefinition rule.
- README: 50 tools, the jdwp_set_fast_breakpoint row and feature section, watchers on fast
  breakpoints, what a reconnect drops; flight #10's debug path says what the tools show, and its
  par is 4 (the consent round trip counts).
- java-debug skill: the fast-breakpoint recipe.
- The probe reads both gate generations before the condition runs: a
  failure of an evaluation that started before a re-arm can no longer
  claim the error generation the re-arm opened.
- stop() is empty; the probe counts the call just before it makes it. A
  step into stop() from a fast stop entered AtomicLong.incrementAndGet.
- Re-arming a fast breakpoint that still waits for its first hit reopens
  both gates: an arming hit whose condition failed had spent the error
  gate, and the next failure never stopped.
- A stop request is retired before it is unmapped, so a queued stop never
  meets a request in neither registry (an untracked stop nobody releases).
- stop() arguments are read again after a resume elsewhere invalidated the
  frame. Unreadable ones publish "reason unknown", never "condition true";
  a suppressed stop with unreadable arguments reopens every gate it may
  have claimed.
- A fast breakpoint's arming hit is handled before a fast logpoint's in the
  same event set: the logpoint's arming redefined the class and lost the
  breakpoint's hit, possibly its only true one.
- An approved restore of a leftover follows the redefinition rule: refused,
  with nothing changed, while a thread is stopped in a restored method.
- A leftover probe of an unknown kind is STRANDED, never offered for
  restore.
- A field logpoint whose bindings cannot be built records
  FIELD_LOGPOINT_ERROR instead of stopping (its set was routed as never
  stopping).

Docs: breakpoints.md 5c and 5d.
- A tool's job runs only at the stop it was started at: InvocationRunner
  records the thread's holds, and every lease inside the job is refused
  once none of them is left (SuspensionSession.admittedUnder). An
  evaluation that outlived its 20 s wait could run at the thread's next
  stop, even at the same line, which is another activation.
- Frame tools refuse a thread while a job runs on it, also before the job
  leases the thread (still compiling).
- The watchdog decides with the invoking thread suspended and re-checks,
  once the owner is proven parked, that it still owns the monitor: a stale
  owner sample could cancel an unrelated new stop.
- The [STILL_RUNNING] report waits at most 2 s for JDI to say what blocks
  the thread.
- A job of an ended connection no longer refuses a thread of the next one
  with the same ID.
- A set routed as never stopping never publishes, not even a handler's
  fail-safe stop: the redefinition rule did not count its thread.
- A leased thread whose stop was released mid-evaluation counts as an
  unknown hold for the redefinition rule until the evaluation ends.

Docs: event-pipeline.md 14 and the fail-safe rule, threading-and-safety.md 5.
@novoj
novoj marked this pull request as ready for review October 30, 2026 09:28
@novoj
novoj merged commit 6cd720c into main Oct 30, 2026
1 check passed
@novoj
novoj deleted the feat/fast-breakpoint-42 branch October 30, 2026 09:28
novoj added a commit that referenced this pull request Oct 30, 2026
…on ownership

Minor release for PR #41 and PR #46. jdwp_set_fast_logpoint (#40) and
jdwp_set_fast_breakpoint (#42) evaluate a logpoint or a breakpoint condition inside
the target JVM through an injected probe class, so hits that don't matter never
involve the debugger. Both rewrite application bytecode and arm nothing without
userApproved=true. The original bytecode is restored on clear, reset, disconnect and
shutdown; a method a stopped thread is in is never redefined; leftovers of a dead
server are listed on the next attach and restored only with approval.

Suspension ownership (#45, folding in #43 and #44): the listener only drains and
routes, event sets are processed in order on a dispatcher thread, every stop is held
in its own context and released only by its owner, and evaluations are leased,
bounded to 20 s ([STILL_RUNNING], EVALUATION_COMPLETED_LATE) and watched for monitor
deadlocks. Objects the debugger creates in the target are pinned against GC.

Both features went through adversarial Codex design reviews and a code review before
merge; the MAJOR findings are fixed, the rest is tracked in #47. Test flight #10
"The Oversold House" adds a highly concurrent sandbox target.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant