Skip to content

Breakpoints: aim at a step point, enable/disable, and a manager view - #481

Merged
ericwinger merged 31 commits into
mainfrom
eric/issue262-breakpoints
Aug 31, 2026
Merged

Breakpoints: aim at a step point, enable/disable, and a manager view#481
ericwinger merged 31 commits into
mainfrom
eric/issue262-breakpoints

Conversation

@ericwinger

@ericwinger ericwinger commented Aug 22, 2026

Copy link
Copy Markdown
Member

Closes #262.

Setting a breakpoint meant a gutter click, which lands on the leftmost step point of the line. A Smalltalk line routinely holds several, so the one you wanted was often not the one you got — and there was no way to disable a breakpoint, act on all of them at once, or see what the session actually had.

The rule everything follows

A GemStone breakpoint lives in the gem. It is not in the repository — no commit persists it, verified on 3.6.2 and 3.7.5 — so it cannot outlive the thing it was set in. VS Code's breakpoint list is the working model (which is what makes the gutter, the per-breakpoint checkbox and the built-in Enable/Disable/Remove All controls drive GemStone for free), but deliberately not a durable one. A breakpoint is dropped from that list, not just from the gem, when:

  • the session logs out — and anything VS Code's own cross-restart persistence brings back on startup is pruned;
  • the method is recompiled — the new method is a different object, and after an edit the same step point may be different code, so re-applying would move the breakpoint somewhere the developer never put it.

Both of those were the opposite way round in the first draft of this branch. They changed because a marker that promises to stop execution it cannot stop is worse than no marker.

What you get

Aiming at a step point. Precision rides on the breakpoint's column: a gutter click carries none and means the leftmost step point on the line, while an inline breakpoint or Toggle Breakpoint at Cursor (Shift+F9) carries the exact column and picks the step point nearest it. The token that will actually break is outlined — grey and faded when disabled, matching the grey the gutter dot goes.

Enable / disable. Per breakpoint from the Breakpoints view checkbox or the cursor commands; Enable All / Disable All / Remove All cover every GemStone breakpoint, including ones Jasper never set. A disabled breakpoint is applied as set-then-disable, because disableBreakAtStepPoint: is a no-op on a step point with no breakpoint.

Step points, numbered but out of the way. Numbers are inlay hints — VS Code's own dim style, suppressed or restyled by its editor.inlayHints.* settings like any other — each clickable to toggle a breakpoint there. gemstone.stepPoints.display defaults to debugging, so they appear while a debug session runs and stay out of the way while you are reading or writing code. Hovering a step point always reports its number and breakpoint state whatever the setting. Numbers are withheld while a buffer has unsaved edits, since the stone's offsets no longer describe the text on screen.

A breakpoint manager. A Breakpoints view in the GemStone sidebar lists what the gem actually holds, grouped by class and method with the step point each breakpoint resolved to. It is the gem's truth rather than a copy of VS Code's list, so it also surfaces breakpoints Jasper never set — from topaz, or a halt left in the code — which were invisible until execution stopped on one.

Break on entry by naming a method. The + button in VS Code's Breakpoints panel takes a name rather than a location. Type a bare selector and Jasper finds the implementors, asking which class you meant when there is more than one; a qualified Account>>balance or Account class>>new skips the prompt but is still checked against the image, since a typo would otherwise set a breakpoint that silently never fires. The name is then replaced by an ordinary breakpoint on the method's first step point, rather than carried as a second kind of breakpoint — so it gains a real location and red dot, enable/disable, and a row in the manager view, and cannot fight a source breakpoint over which of them owns a method's breaks.

Everything works in the debugger's source pane too, since the debugger opens ordinary editors.

Unifying two managers that fought each other

The old cursor-toggle command kept its breakpoints in a private map, so they never appeared in the Breakpoints view and were silently wiped whenever a gutter breakpoint on the same method was applied (that path calls clearAllBreaks first). It now adds a normal VS Code breakpoint, so the two kinds are one kind.

Bugs found along the way

  • A gutter breakpoint could land one step point late. Line-to-step-point mapping compared GemStone's 1-based source offsets against 0-based line offsets, so a step point sitting exactly on a line boundary was read as belonging to the previous line. Run to Cursor already corrected for this; the gutter path did not.
  • _allMethodBreakpoints' tuple stride is version-dependent — 3 fields on 3.6.2, 4 on 3.7.5, which gained breakpointLevel. Decoding it by hand walks off the end of the array on the older release. getAllBreakpoints reads the kernel's own _breakReport: instead, which knows its own stride. It also skips breakpoints stranded on a superseded method version, which a recompile leaves behind and the gem goes on reporting.
  • Cursor commands failed silently in four cases — a non-method editor, an unsaved buffer, a diff view, a method whose step points can't be read. Invoked from a keystroke, a silent no-op is indistinguishable from a dead keybinding. Each now says why.
  • A named breakpoint's implementor search skipped environment 0. gemstone.maxEnvironment is a ceiling, not a selection, and was passed straight through as the environment to search — so on a stone configured above 0 an ordinary selector came back as "nothing implements it". It now sweeps 0..max and remembers which environment each hit came from.
  • VS Code's + creates the breakpoint blank and only then opens it for editing, and the typed name arrives as a change, not an addition. Rejecting the blank deleted the row before it could be typed into; watching additions alone never saw the name.

Known limitation

VS Code's own Deactivate Breakpoints button greys the breakpoints out but cannot reach GemStone: vscode.debug exposes no activation state and no event for it, so an extension cannot observe the flag and the breakpoints stay armed in the gem. Disable All Breakpoints is the one that disarms them. Documented in the README.

Testing

npm run lint, npm run format:check, npm run compile and npm test all clean: 6152 client, 322 server, 92 mcp-server.

breakpoints.integration.test.ts pins the GemStone semantics the design rests on against a live stone, since none of them is documented anywhere we control — the disable-is-a-no-op rule, set-as-enable, out-of-range being silently ignored, breakpoints surviving a transaction abort, and the version-independence of the breakpoint report. Run against both 3.6.2 and 3.7.5.

Manually verified end to end in the Extension Development Host across 20 checks: gutter and cursor breakpoints, the panel and the manager view, enable/disable individually and globally, step point numbering and its dirty-buffer suppression, hover, reveal-from-list, the four global toolbar actions, all six named-breakpoint cases, actually stopping on a breakpoint, recompile, logout, and class-side methods.

🤖 Generated with Claude Code

ericwinger and others added 3 commits August 21, 2026 18:13
Setting a breakpoint meant a gutter click, which lands on the leftmost step
point of the line. A Smalltalk line routinely holds several, so the one you
wanted was often not the one you got — and there was no way to disable a
breakpoint, act on all of them at once, or see what the session actually had.

Make VS Code's own breakpoint list the durable model. GemStone method
breakpoints are per-gem VM state: they do not survive logout and a commit does
not persist them (verified against 3.6.2 and 3.7.5), so the stone can only ever
say what one session currently has, never what the developer wants. With VS
Code's list as the record, breakpoints survive a restart and the gutter, the
per-breakpoint checkbox and the built-in Enable/Disable/Remove All controls all
drive GemStone through onDidChangeBreakpoints. Jasper re-applies them on login
and after a recompile.

Step point precision rides on the breakpoint's column: a gutter click carries
none and means the leftmost step point on the line, while an inline breakpoint
or Toggle Breakpoint at Cursor (Shift+F9) carries the exact column and picks the
step point nearest it. The token that will actually break is outlined, dashed
when disabled. A disabled breakpoint is applied as set-then-disable, because
disableBreakAtStepPoint: is a no-op on a step point with no breakpoint.

Number step points as inlay hints — VS Code's own dim style, suppressed by its
editor.inlayHints.* settings like any other — each clickable to toggle a
breakpoint there. gemstone.stepPoints.display defaults to `debugging`, so the
numbers appear while a debug session runs and stay out of the way while you are
reading or writing code. Hovering a step point always reports its number and
breakpoint state whatever the setting. Numbers are withheld while a buffer has
unsaved edits, since the stone's offsets no longer describe the text on screen.

Add a Breakpoints view listing what the gem actually holds, grouped by class and
method with the step point each breakpoint resolved to and a checkbox per row.
It is the gem's truth rather than a copy of VS Code's list, so it also surfaces
breakpoints Jasper never set — from topaz, or a halt left in the code — which
were invisible until execution stopped on one.

Unify the two managers that fought each other: the cursor-toggle command kept
its breakpoints in a private map, so they never appeared in the Breakpoints view
and were silently wiped whenever a gutter breakpoint on the same method was
applied. It now adds a normal VS Code breakpoint, so the two kinds are one kind.

Two bugs found on the way:

- Line-to-step-point mapping compared GemStone's 1-based source offsets against
  0-based line offsets, so a step point on a line boundary was read as belonging
  to the previous line. Run to Cursor already corrected for this; the gutter
  path did not.
- _allMethodBreakpoints' tuple stride is version-dependent — 3 fields on 3.6.2,
  4 on 3.7.5, which gained breakpointLevel — so decoding it by hand walks off
  the end of the array on the older release. getAllBreakpoints reads the
  kernel's own _breakReport: instead, which knows its own stride. It also skips
  breakpoints stranded on a superseded method version, which a recompile leaves
  behind and the gem goes on reporting.

The GemStone semantics all of this rests on are pinned by an integration test
against a live stone, since none of them is documented anywhere we control.

Renames gemstone.toggleSelectorBreakpoint to gemstone.breakpoints.toggleAtCursor
with no alias, so a custom keybinding naming the old id needs the new one.

Closes #262

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Toggle/Enable/Disable Breakpoint at Cursor returned silently in four cases — a
non-method editor, an unsaved buffer, a diff view, and a method whose step points
could not be read. These are all invoked deliberately, from a keystroke or a menu,
so a silent no-op is unreadable: it looks exactly like a dead keybinding, and the
developer has no way to tell "your buffer is unsaved" from "the command never ran".

Give StepPointModel an `explain` that answers either the step points or the reason
there are none, phrased as something the developer can act on, and report it. `get`
stays as the quiet wrapper for the hover and the inlay hints, which have nothing
useful to say about a method without step points and must not nag.

The unsaved-buffer case is the one most likely to be hit and least likely to be
guessed: step point offsets describe the compiled method, so acting on a modified
buffer would put the breakpoint somewhere other than where the caret is pointing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
VS Code persists its breakpoint list across restarts and restores it at startup
— silently, without firing onDidChangeBreakpoints, and before any session
exists. Nothing then pushed those breakpoints into a gem, so reopening the
window left a red gutter marker with nothing behind it: the marker claimed
execution would stop somewhere it wouldn't, and running the method quietly did
not break. reapplyAll existed for exactly this but was only ever reachable from
the manual "Re-apply Breakpoints to Session" command.

Hook it to onDidChangeSelection, which a login fires, and scope it to
breakpoints whose URI names that session. Method URIs carry the session id, so
an unscoped re-apply would push one session's breakpoints into another
session's gem — a breakpoint in a stone the developer never asked about. Session
ids restart at 1 per window, so the usual first login reclaims a restored
breakpoint; one naming a session that doesn't exist waits rather than leaking.

reapplyAll now returns the number of methods it applied, so the manual command
can say what it did instead of appearing to do nothing when there was nothing
to do.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@ericwinger
ericwinger marked this pull request as draft August 24, 2026 16:59
ericwinger and others added 13 commits August 24, 2026 10:02
A GemStone breakpoint lives in the gem, so it cannot outlive the session. VS
Code persists its breakpoint list across restarts anyway — right for a file,
wrong for a gem — so a reopened window showed a red marker for a breakpoint that
existed nowhere. The previous commit made that marker true by re-applying it to
whichever session logged in next; that was the wrong fix. It resurrects a
breakpoint into a gem the developer never pointed it at, and it treats a
transient thing as durable.

Drop them instead:

- logging out removes that session's breakpoints from VS Code's list, scoped by
  the session id in the method URI so another session's are untouched;
- activation prunes anything a startup restore brought back, and an `added`
  event prunes a restore that lands later or a gutter click in a stale editor.

Pruning is idempotent, so the removal it triggers re-enters the handler with
nothing left to prune and cannot loop.

Removes "Re-apply Breakpoints to Session", whose only purpose was to serve the
persistence model being dropped.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The marker that says *which* step point a breakpoint sits on distinguished
disabled from enabled by border style alone — dashed versus solid, 1px, in a
grey that some themes render nearly invisibly. Nobody notices that, so
"is this breakpoint live?" — the one question the marker exists to answer — was
unanswerable by looking.

Lead with colour instead, the same red-versus-grey pair VS Code uses for the
gutter dot, and fade the disabled token so the cue survives a theme where the
grey border is lost.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Toggle Step Point Numbers was Command Palette only, so turning the numbers on
meant leaving the source you were reading them against. Put it on the editor
title bar as a one-click button and on the editor right-click menu, next to the
other breakpoint actions.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The + button in VS Code's Breakpoints panel creates a function breakpoint —
named rather than located. Jasper only implements source breakpoints, so one sat
in the panel looking exactly like a working breakpoint and never fired. Unsaid,
that reads as "breakpoints are broken" rather than "this kind isn't wired up".

Warn on add. Deliberately does not delete it: the developer typed it on purpose,
and silently removing what someone just typed is its own kind of confusing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The + button in VS Code's Breakpoints panel takes a method name rather than a
location, and VS Code leaves resolving that name entirely to the debugger — so
one sat in the panel indistinguishable from a working breakpoint and never
fired. The previous commit only warned about it; naming a method you want to
stop in, without going to find it first, is worth actually supporting.

A bare selector is looked up across the image and, when more than one class
implements it, the developer picks which one. A qualified `Account>>balance` or
`Account class>>new` skips the prompt, but is still checked against the image
rather than trusted — a typo would otherwise set a breakpoint that silently
never fires, which is the failure this whole change is about.

The name is then **converted** into an ordinary SourceBreakpoint on the method's
first step point, rather than carried through the model as a second kind of
breakpoint. That gives it a real location and red dot, enable/disable, death
with its session and a row in the GemStone Breakpoints view, all for free — and
avoids a named and a located breakpoint on one method fighting over which of
them owns its breaks, since applying a method's breakpoints clears the method
first.

A name that resolves to nothing is dropped with an explanation rather than left
sitting there, which is the same reasoning: an inert breakpoint that looks live
is worse than no breakpoint.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
handleAdded is fired without awaiting, since resolving a name can prompt. Any
error inside it therefore became an unhandled rejection: the breakpoint stayed
in the panel, unconverted and inert, with nothing said — the very failure the
resolver exists to remove. Catch and report, and trace each decision (parsed
name, candidates found, chosen class, resulting URI) to the GemStone log so a
failure in the field is diagnosable without a debugger.

Also guard the one input that would build a broken URI: implementorsOf reports
no dictionary for a class not bound under its own name in the symbol list, and
an empty dictionary segment yields a URI the file system provider cannot
resolve.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
VS Code's + button in the Breakpoints panel creates the function breakpoint
*first*, with an empty name, and only then opens it for inline editing. Two
consequences, both wrong here:

- the empty name arrived as an addition and was rejected as unresolvable, which
  removed the row and popped a warning before a single character could be typed;
- the name the developer actually types arrives as a **change**, not an
  addition, so watching additions alone never saw it.

Treat a blank name as "still being typed" and leave it strictly alone, and
resolve from added and changed together. Editing an existing breakpoint's name
now works for the same reason.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The filled-dot button in the Breakpoints panel header greys every breakpoint out,
which reads as "these are off now". But `vscode.debug` exposes no activation
state and no event for it, so an extension cannot observe the flag: Jasper's
breakpoints stay armed in the gem and execution still stops on them. It sits one
icon away from controls that do work, so it is worth naming rather than leaving
to be discovered mid-debug. Disable All Breakpoints is the one that disarms them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`gemstone.maxEnvironment` is a ceiling, not a selection, and the resolver passed
it straight to implementorsOf as the environment to search. On a stone
configured above 0 that skipped environment 0 — where practically every method
lives — so a perfectly ordinary selector came back as "Nothing implements
#usesPrimary" while the Explorer listed it two panes away.

Sweep 0..maxEnvironment and dedupe by class and selector, which is what the
standalone senders and implementors commands already do. Each candidate now
carries the environment it was found in, so the breakpoint is set against that
environment rather than the configured ceiling — the same bug one step later.

Verified against the dev stone: implementorsOf itself was answering correctly
all along.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Re-applying them to the new method was wrong twice over. The recompile replaces
the GsNMethod, so the gem's breakpoints on the old one are unreachable — and the
new method's step point 4 may be an entirely different expression after an edit,
so re-applying moves the breakpoint somewhere the developer never put it. The
expectation is simply that a recompile clears them.

Drop them from VS Code's list as well as the gem, so the gutter, the Breakpoints
panel and the GemStone Breakpoints view agree — the same rule that already
applies when a session logs out. The step point cache is still invalidated,
since the offsets have moved either way.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two claims were left over from the first design, both now false: that VS Code's
breakpoint list is a durable record surviving a restart (breakpoints die with
the session), and that a disabled breakpoint's marker is dashed (it is grey and
faded — the dashed border was too subtle to see). Also reorder the section so
the headline feature leads rather than sitting mid-list behind the lifecycle
rules, and move the Deactivate Breakpoints limitation under Documentation, which
is what it is.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
VS Code's Edit Breakpoint accepts a condition, a hit count or a log message, and
honouring them is the debugger's job. Jasper does not implement any of the three,
so the breakpoint stopped every time it was reached with nothing said — the worst
shape this failure takes, because the developer wrote down a precise intent and
the UI accepted it.

Warn instead. The fields keep riding along through enable/disable and
name-conversion, so nothing is lost when conditional breakpoints arrive (#277).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@ericwinger
ericwinger marked this pull request as ready for review August 24, 2026 19:39
@ericwinger
ericwinger requested a review from npapagna August 24, 2026 19:39
ericwinger and others added 6 commits August 25, 2026 14:37
The CHANGELOG conflict is a plain union: both sides added to [Unreleased],
so every entry is kept and the headings are put back in Keep a Changelog
order, which main's section had drifted from.

Main's safe-delete work gave MethodSearchResult a required environmentId.
This branch had been carrying that field itself, as a Candidate type that
intersected the search row with an environment because the row had none —
so the type is now a no-op and its comment is untrue. Candidate goes, the
row is used directly, and the environment stamp the sweep applied on top
of each result goes with it: implementorsOf already stamps the row with
the environment it searched. The test's mocks now do the same, since a
test asserting "found in environment 1" should not be asserting against a
row that says 0.

Main also added a scan that fails the build on a dedup key which leaves
the environment out, with an allowlist for the choosers that fold a
selector to one row on purpose. The class picker behind a named
breakpoint is one of those — one class is one entry, the lowest
environment wins, and that environment rides along to where the
breakpoint is set — so it joins GemStone Search on the allowlist, with
the reason recorded at both ends.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Only the CHANGELOG conflicts, and only because both sides added under
[Unreleased]. Every entry is kept, in Keep a Changelog heading order.

The external-server work does not touch the query layer this branch
builds on, so nothing needed reconciling beyond that.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
No conflicts, and nothing on this side needed changing: the compile-check
work lands in the refactoring engine and its payload, which this branch
does not touch.

The engine payload moved, so the test stone's copy predates the merge.
Reinstalled it before running the gate -- otherwise the engine-gated
suites would have been testing the old engine and passing on it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The release moved everything out of [Unreleased] and under a version
heading, so a union of the two sides would have been wrong twice: it
would have pulled 16 already-shipped entries back into [Unreleased], and
left this branch's own entries inside a release they were never part of.

Resolved by taking main's file and putting back only the entries this
branch has under [Unreleased] that do not appear in 1.8.14 -- the ten
that describe the breakpoint work, which has not shipped. The version
bump comes from main.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two dependency bumps, package.json and package-lock only; nothing on this
side needed changing. Both are majors, so both were actually exercised
rather than assumed: eslint runs clean under globals 17, and the suite is
green.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

@npapagna npapagna left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks @ericwinger! Left a couple of comments.

Comment thread client/src/breakpointManager.ts Outdated
Comment thread client/src/breakpointManager.ts
Comment thread client/src/breakpointManager.ts
Comment thread client/src/breakpointManager.ts Outdated
Comment thread client/src/breakpointManager.ts
Comment thread client/src/functionBreakpoints.ts Outdated
Comment thread client/src/queries/getAllBreakpoints.ts Outdated
Comment thread client/src/breakpointManager.ts
Comment thread client/src/gemstoneDebugSession.ts
Comment thread client/src/stepPointModel.ts
ericwinger and others added 2 commits August 27, 2026 12:13
A breakpoint names a step point in a method that lives in the gem, so the
positions it is placed by only mean something where the editor's text *is*
that compiled method's. Two paths did not hold to that.

Ad-hoc executed code. A stack frame with no gemstone:// path is either an
"Executed Code" doit or a method whose class is not bound in the symbol
list. Neither has anything durable to arm -- a doit's method is compiled for
one execution and gone afterwards -- but the debug adapter tried anyway, on
a best-effort line match that ignored the requested column, bypassed
BreakpointManager entirely (so the result was invisible to the breakpoint
view, Enable All, Disable All and pruning), and answered an unknown source
reference with an empty list, which VS Code reads as acceptance. It is now
refused per breakpoint with a reason, so the marker greys out and hovering
it says why. mapLineToStepPoint went with it; the branch was its only
caller.

Unsaved edits. Step point offsets describe the compiled method while VS
Code shifts its own breakpoints as the buffer is edited, so a dirty editor
makes the two describe different code. Toggle Breakpoint at Cursor already
refused on that basis; the gutter and the debug adapter did not. Because
breakpoints are applied as an absolute model -- clear the method, then
re-arm the whole list by position -- adding one to a dirty editor silently
re-resolved every breakpoint on that method against offsets that no longer
matched, moving ones the developer had not touched. A dirty editor now
refuses a new breakpoint, taking it back out of the list, and the gem is
left untouched meanwhile, so reverting the editor leaves the original
breakpoints exactly where they were. Once it is clean again the gem catches
up with anything the list did during the hold.

Both refusals name the two ways on: save the method, or File: Revert File.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ured

VS Code decides where its breakpoint gutter appears by language, with no
way to narrow that by document -- and one language, gemstone-smalltalk,
serves four different things: gemstone:// method editors, workspaces, .gst
files, and the read-only source of a stack frame. Only the first can carry
a breakpoint. The gutter was offered in all four, and a breakpoint set in
the other three was dropped on the floor: a solid red dot that armed
nothing, stopped nothing, and said nothing, which is indistinguishable from
a breakpoint that simply never gets hit.

The stack frame source no longer claims a language. sourceRequest used to
answer with mimeType 'text/x-gemstone-smalltalk', which the language
declares in package.json, so the document resolved to gemstone-smalltalk
and got a gutter. Withholding it costs syntax highlighting in that
read-only view -- the debugger panel's own source pane still highlights --
and buys a gutter that never invites a breakpoint it must refuse. The debug
adapter still refuses such a request outright, as the backstop for
debug.allowBreakpointsEverywhere or a client that is not VS Code.

A breakpoint set in a workspace or a .gst file is taken back out of VS
Code's list with a message naming where it belongs. The test for that is
deliberately narrow: onDidChangeBreakpoints reports every extension's
breakpoints, so a rule of "not a gemstone:// URI" would have taken a Python
file's breakpoint out of the developer's Breakpoints panel -- a worse bug
than the one being fixed. It matches on the language instead, falling back
to the file extension for one restored before its editor is open, and
leaves everything else alone.

Also corrects the comment, README bullet and changelog entry that claimed
VS Code would not offer the gutter on a doit frame. It did.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
ericwinger and others added 5 commits August 27, 2026 14:25
Correctness, in the order it matters:

- A breakpoint is applied to the session its method belongs to, not to
  whichever session is selected. A method editor stays bound to the session
  it was opened from, so with two sessions live the selected one is routinely
  not the gem holding the method: the break landed in the wrong stone, the
  method on screen never stopped, and the clear that precedes re-arming took
  out breakpoints the other session had set. Enable/Disable/Remove All sweep
  every live gem for the same reason, since the rows they flip are one list
  spanning all sessions, and one gem refusing no longer abandons the rest. A
  row in the breakpoints view can only match a method from the session it was
  read from.

- A GCI failure while arming is no longer swallowed. The stone's message is
  shown and carried back in the breakpoint result, and a step point that
  armed but could not be disabled is cleared rather than left stopping
  execution behind a marker that reads "disabled". When a method's step
  points cannot be read after its breaks were cleared, the stale record is
  dropped so no marker, hover or view row outlives it.

- A keyword message's continuation keywords are found past a separator
  nested in parentheses or a block, so the trailing part of a send keeps its
  step point number, hover and breakpoint.

- A named breakpoint deleted while its class picker was open is no longer
  resurrected when the picker answers.

- The step point numbers toggle claims its new value before writing the
  setting, so a fast second click flips back instead of being lost.

Reuse and cost:

- The duplicated step point resolver is gone: Run to Cursor now goes through
  the shared resolver, so it and a breakpoint set in an editor cannot
  disagree about which token a step point is.
- Method names, error text and the symbol list dictionary lookup all use the
  existing shared helpers instead of local copies. The shared method-name
  helper takes just the three parts of a name so every caller can reach it.
- Step points are read in one round trip to the stone instead of three, which
  is extension host time the editor was not drawing in.

A stale comment claiming Run to Cursor is unavailable on Executed Code frames
is corrected: such a frame is breakable by method OOP.

Manually verified against a live stone by Eric: the session a breakpoint is
applied to, the view row matching only its own session, Disable All across
sessions, the keyword scan, the numbers toggle, and Run to Cursor through the
shared resolver.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Tests first, since three of the six findings were untested claims:

- The one-round-trip step point query had never executed against a stone. It
  replaced three proven queries on the hover and inlay-hint path, and every
  test mocked it away, so a syntax error or any difference in sourceString /
  _sourceOffsets between releases would have stopped step point numbers
  entirely with the suite still green. Three integration cases now compare its
  three parts against the three queries it replaced, prove the reply is not
  merely empty by landing a selector range on real source text, and pin the
  framing with a method whose source carries tabs, blank lines and text shaped
  like the reply's own header rows.

- The debug adapter's relay of a refusal reason was untested on the branch
  that carries it: a refused breakpoint reports the reason, an accepted one
  reports neither reason nor message, and the column of an inline breakpoint
  is forwarded.

- A workspace breakpoint is refused whether or not a session is logged in.
  The refusal deliberately runs before the session lookup, and nothing pinned
  that.

One leak, with the behaviour it could reach:

- A method held still while its editor was dirty was only ever released by a
  revert. A save arrives as a recompile instead, and VS Code fires no text
  change for it, so the URI stayed held for the life of the window and a later
  unrelated clean edit would re-apply the method. Released on recompile, and
  swept when the session logs out.

Documentation:

- README says a breakpoint belongs to the session its method was opened from,
  and that Enable/Disable/Remove All sweep every logged-in session — a real
  behaviour change for anyone running concurrent sessions, previously written
  down only in the CHANGELOG.
- CHANGELOG entries for four fixes that had none: the resurrected named
  breakpoint, the one-round-trip step point read, the method-name format, and
  the step point numbers toggle.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two conflicts, both resolved as a union:

- CHANGELOG.md — rebuilt from the whole file rather than the conflict hunk, so
  no section lost bullets that sat outside it. Every bullet from both sides is
  present; main's Added, Removed and Fixed entries now sit beside the
  breakpoint work's own.
- package.json — main added the GemStone Go Back / Go Forward commands next to
  the command this branch renamed. Both navigation commands are kept, and the
  breakpoint command keeps its new name (`gemstone.breakpoints.toggleAtCursor`);
  no reference to the old `gemstone.toggleSelectorBreakpoint` survives anywhere.

`npm install` run afterwards, since main moved esbuild to devDependencies and
pinned the @types floor.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@ericwinger

Copy link
Copy Markdown
Member Author

Thanks @npapagna — all fourteen are addressed, each with a reply in its own thread and the thread resolved. Branch is up to date with main.

Both blockers. The swallowed GCI failure now reports the stone's message and, in the case you spotted, removes a break that armed but could not be disabled rather than leaving it stopping execution behind a marker that reads "disabled". The doit/decompiled blocker went a different way than you asked: the case is now refused rather than threaded through BreakpointManager, because a doit's method is compiled for one execution and gone afterwards, so a breakpoint on it could never be hit again. That is a decision worth your eye — the reasoning is in that thread, along with the residual filed as #515.

One premise correction, because it changes severity rather than the fix. The three session-scoping findings were called latent on the grounds that Jasper supports only one open session. Concurrent sessions ship today behind gemstone.sessionMode: "multiple", documented in the README and in the CHANGELOG entry that introduced Logins & Sessions — and the README's own note describes the exact state the code mishandled: "an open workspace/editor stays bound to the session that opened it even after you switch the active session." All three were reachable by a developer flipping one documented setting, and all three are fixed and tested.

Verified by hand against a live stone, by Eric — as distinct from the rest, which rest on automated tests:

  • a breakpoint is applied to the gem its method was opened from, not the selected session;
  • a Breakpoints view row disables only its own session's breakpoint;
  • Disable All reaches every logged-in session's gem;
  • the keyword scan carries past a separator nested inside brackets;
  • the step point numbers toggle survives a fast double-click;
  • Run to Cursor through the shared resolver stops at the send nearest the cursor on an Executed Code frame.

Also in this round, from a second pass over the branch after your review:

  • the one-round-trip step point query had never executed against a stone — every test mocked it, so a syntax error or a release difference in sourceString / _sourceOffsets would have stopped step point numbers entirely with the suite green. It now has ungated integration cases comparing its three parts against the three queries it replaced, proving the reply is not merely empty, and pinning the framing with source containing tabs, blank lines and header-shaped text;
  • the args.source.path branch of setBreakpointsRequest — the only path that now carries a refusal reason to the developer — has tests for the reason arriving, for an accepted breakpoint carrying none, and for the column being forwarded;
  • a method held still while its editor was dirty was only ever released by a revert, never by a save, so the URI leaked for the life of the window; it is released on recompile and swept on logout;
  • CHANGELOG entries for four fixes that had none, and the README now states that a breakpoint belongs to its own session's gem and that Enable/Disable/Remove All sweep every logged-in session.

Every source change was mutation-checked: reverted in turn, with the suite required to go red. Gate is green — lint, formatting, compile, 6,818 client tests, 322 server, 92 mcp, including the live-stone integration tests.

@ericwinger
ericwinger requested a review from npapagna August 27, 2026 21:48

@npapagna npapagna left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks @ericwinger! 🚀

@ericwinger
ericwinger added this pull request to the merge queue Aug 31, 2026
Merged via the queue into main with commit 5c1a95c Aug 31, 2026
21 checks passed
@ericwinger
ericwinger deleted the eric/issue262-breakpoints branch August 31, 2026 17:05
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Breakpoint support & step point support

2 participants