4. Exception handling

Delphi codebases raise exceptions as control flow. A debugger that stops on every one of them is unusable on a real project, and a debugger that stops on none of them is useless. This chapter is about landing in between.

The four filters

The BREAKPOINTS view lists four exception filters. They are the coarse control, and for many projects they are enough.

FilterStops onDefault
delphiFirst-chance Delphi language raiseson
avFirst-chance access violationson
allEvery first-chance exception, including native onesoff
unhandledSecond-chance — the ones that actually crash the programalways on
unhandled is the one filter you cannot switch off. Unticking it in the BREAKPOINTS view has no effect — the adapter keeps it armed regardless, because silently swallowing a second-chance exception almost always wedges the program, and a debugger that lets you do that by accident is not doing its job.
The BREAKPOINTS view with the four exception filters, their real labels and default ticks. The mouse hovers the Delphi-raised exceptions row, showing its pencil icon and an Edit Condition tooltip. The Edit Exception Rules title-bar icon is called out.
The four filters with their real labels and default ticks. Hovering the delphi row reveals its pencil — Edit Condition… — which is where the delphiExceptionClasses allowlist gets set from the GUI; the other three filters have no such affordance, since they are plain on/off. The callout is the Edit Exception Rules… title-bar icon, covered below — it only appears when the mouse is over the view.

A first-chance break passes the exception through correctly when you continue, so the debuggee’s own try/except still runs. You are looking at the raise, not preventing the handler.

The delphi filter can be narrowed to specific classes — a comma- or semicolon-separated allowlist such as EAccessViolation,EConvertError, empty meaning every Delphi raise. There is no launch.json field for this: unlike rules, filters are never configured from the launch configuration. Two ways to set it instead — the GUI pencil below, or an agent over MCP through the delphiExceptionClasses argument on launch_debuggee, attach_from_config or set_exception_filters (MCP).

VS Code exposes that same allowlist through the standard pencil icon it draws next to a filter row on hover — its own generic mechanism for a per-filter condition string, which this adapter wires up for the delphi filter only. It is not a second, competing rule language: typing there applies the same narrowing MCP’s delphiExceptionClasses does, just through the DAP setExceptionBreakpoints channel instead of a tool call. The rule engine below is the tool for anything past a flat class allowlist — per-unit, per-line, by message, or with an action other than break.

What you see on a stop

The debugger reads the exception object’s class name and its Exception.FMessage. For a native access violation with no Delphi object behind it, it synthesises a description instead — “Access violation at $RIP reading address $fault”.

The live exception object itself appears in Locals as a synthetic $exception row, shown as Class: Message and expandable like any other object. It works in Watch and hover too, so you can evaluate $exception.Message or drill into a custom exception class’s own fields.

Stopped on the raise itself, with the $exception row expanded in Locals showing Disposed, BaseException, HelpContext, InnerException, Message = 'Test error', StackTrace and StackInfo, alongside the ordinary locals localdata and foo.
A first-chance stop right on the raise line, before control has reached any handler. $exception is expandable like any other object — here showing Message plus the rest of Exception’s own fields and properties.

That is the exception object at the instant it was raised. What happens once you are actually inside a handler — still stopped several lines later, on an ordinary breakpoint that has nothing to do with the exception itself — is its own thing, and it is deliberately different depending on whether the handler gave the exception a name.

Inside the handler: aliased vs. anonymous

on E: Exception do declares E as an ordinary local, scoped to that clause’s block — it shows up in Locals under whatever name you gave it, for as long as the stop is anywhere inside that block, not only at the moment of the raise. A bare except .. end with no on clause names nothing, so there is no local to show; instead the block gets its own $exception row, live for the same extent. The two are mutually exclusive by construction — a handler is never showing both at once, because a block is either aliased or it is not.

Both are ordinary operands past that point: E.Message = 'Test error' or $exception is EMyError work as breakpoint conditions exactly as well as they work in a watch or a hover, because they go through the same expression evaluator either way — typing $exception into Watch is a shortcut for the same lookup, not a separate mechanism. Stopped somewhere with no exception in scope at all, both report why rather than a bare parse error or an empty value.

Locating the handler block this way needs .pdata, so it is x64 only — for a handler directly in a project’s .dpr main block, and for a bare except’s $exception staying live past the raise itself. Neither degrades silently: on x86 both report why rather than just not appearing. An on E: alias inside an ordinary function or procedure is unaffected either way — it has always worked as a normal local, on both bitnesses, and is not part of what changed here. See chapter 11.

Creating a rule without writing JSON

Filters are on/off per category. The moment a specific EFileNotFound in one unit is noise but the same class elsewhere is the bug you are chasing, you need a rule — and the fastest way to write one is to let the debugger do it, from the exception you are already looking at. Two commands, and you will use the second one far more often.

Create a Rule for This Exception…

Available only while stopped on an exception, from the floating debug toolbar.

The floating debug toolbar stopped on the raise, with the mouse hovering the rightmost icon showing an 'Create a Rule for This Exception...' tooltip.
The rightmost icon on the floating debug toolbar — it only appears while stopped on an exception, alongside the ordinary continue/step controls.

It reads the exception in front of you and offers ready-made rules built from it — ignore this class everywhere, ignore it only in this unit, ignore only this exact raise site, log it with its stack instead of breaking, or match on a substring of its message. Pick one and it is written for you.

The Create a Rule for This Exception suggestion list: Ignore Exception everywhere, Ignore Exception raised in Debugme, Ignore Exception at Debugme:99, Log Exception with its call stack instead of breaking, Ignore Exception when the message contains "Test error", and Edit a pre-filled rule in the rules editor.
Every suggestion is built from the exception actually stopped on — class, unit, exact raise site and message all come from it, not from a template. The last entry opens the full editor below with the same information pre-filled, for when none of the ready-made ones is quite right.

This is the answer to being interrupted for the third time by something the application handles perfectly well. The new rule is inserted first, which matters: first match wins, so it takes effect regardless of what is already in the list.

Edit Exception Rules…

From the title bar of the Breakpoints or Call Stack view — those icons appear when the mouse is over the view. It works with no session running, which is how you edit the shared rules file on its own.

It first asks where the rules should live — see where rules live for the three files behind this and how they rank. Each option shows how many rules it currently holds; a file that does not exist yet says so instead of a count, since saving there creates it. If there is only one possible target the question is skipped.

The Select where the exception rules live picker, listing Debugme.dproj rules (local) with 0 rules and a note that the file will be created, Debugme.dproj rules (shared) with 1 rule and a note that it travels with the project, and Shared rules (all projects) with 22 rules. The Edit Exception Rules title-bar icon is called out with an arrow.
One local and one shared entry per distinct Delphi project named anywhere in the workspace’s launch configurations — not per configuration, since a project’s launch and attach configurations name the same project and collapse to one pair — plus the always-present global entry last.

Then the editor itself, one card per rule:

  • A plain-language summary of what the rule does, rewritten as you type — class = EAbort AND unit OracleData → ignore, or any exception → break when you have not filled anything in.
  • The nine criteria as labelled fields, each with a hint explaining what it matches, and the action as a dropdown whose help text changes with the selection.
  • Buttons to move the rule up or down — which is what changes the outcome, since evaluation stops at the first match — plus duplicate and delete. Delete asks nothing, so be deliberate.
  • Validation on every keystroke. A malformed regular expression, a line range whose end precedes its start, a missing action, a field name that is not one of the nine — all are listed at the bottom, and the save button stays disabled until they are fixed.
Saving does not finish the job. The editor writes the rules into the target file and opens it beside the editor — and leaves it unsaved. You still have to save that file. This is deliberate: whichever of the three files you picked, you get to see the change before it lands, and a sibling key elsewhere in the file — for a file shaped as { "exceptionRules": [...] } rather than a bare array — survives untouched, since only the rule array itself is replaced.

Closing the editor tab with unsaved edits keeps them: reopen it and it offers to restore what you had. A read-only banner appears instead of the save button if the workspace is not trusted — copy the JSON out in that case.
The Edit Exception Rules editor for the shared rules file, a list of collapsed rule cards with card #6 expanded showing the MATCH IF fields (class, class is, exception code, message contains, message matches regex, unit, line, line from, line to) and the THEN ACTION dropdown set to ignore, with Add rule, Expand all, Copy JSON and Save to the shared rules file buttons.
The tab title names the target — here “Shared rules (all projects)”, the same panel opens on any of the three files. Card #6 is expanded: the nine criteria on the left, the action and its plain-language effect on the right. Collapsed cards show the same plain-language summary this chapter’s earlier bullet list describes, plus the move/duplicate/delete controls on each row.

The rule engine underneath

Both editors above write into whichever of the three files you picked — where rules live covers exactly which files those are and in what order they rank. Inside any of them a rule is a flat JSON object: any number of optional criteria, plus a required action. Knowing the shape is what lets you hand-edit a file faster than reopening the GUI for a one-word change, or write a rule the editor cannot express yet.

Each file is either a bare array of rules, or an object with an exceptionRules key holding the same array — the editor preserves whichever shape it finds:

[
  { "class": "EAbort", "action": "ignore" },
  { "messageRegex": "ORA-\\d+", "action": "logStack" },
  { "class": "EAccessViolation", "unit": "OracleData",
    "lineFrom": 2700, "lineTo": 2800, "action": "break" }
]

Criteria present in a rule are AND-ed; criteria you omit are wildcards. Evaluation is first match wins, top to bottom.

Criteria

KeyTypeMatches
classstring or array The runtime (leaf) class, exactly, case-insensitively. An array matches any of the listed names.
classIsstring or array The runtime class or any ancestor — Delphi is semantics. Use this to catch a whole family.
codestring, integer, or array The Win32 exception code, hex ("0xC0000005") or decimal. The only criterion that can match a native, non-Delphi exception.
messagestring Case-insensitive substring of the exception message.
messageRegexstring Regular expression against the message, case-insensitive.
unitstring The unit the exception was raised in, by basename (the .pas is optional). The special value "*unknown*" matches raises with no resolvable source location.
lineinteger A single source line — shorthand for setting lineFrom and lineTo to the same value.
lineFrom / lineTointeger Inclusive bounds of a source-line range.

Actions

ActionEffect
ignoreResume silently
logWrite class: message to the console, then resume
logStackThe same, plus the call stack
breakStop in the debugger
action is mandatory, and a rule whose action is missing or misspelled is dropped without a message in the Debug Console — the note goes to the adapter’s log file, which is off by default. If a rule appears to have no effect at all, check its action spelling first.

Where the criteria come from

unit, line and the line range refer to the raise site — the first stack frame with known source — not to the address the OS reported the exception at. Using them makes the adapter walk the stack on every exception, which is a real cost with the all filter switched on.

Two consequences worth knowing before you write rules against them. unit: "*unknown*" means there is no raise site, so any line criteria on that same rule can never be evaluated. And because line seeds both bounds before lineFrom / lineTo are applied, { "line": 100, "lineTo": 200 } means the range 100 to 200, not line 100.

class also matches the synthesised names the debugger uses for exceptions with no Delphi object — EAccessViolation for an access violation, and Exception 0x… forms for other native codes.

One exception can never be matched by a rule: TThread.NameThreadForDebugging raises code 0x406D1388 purely to announce a thread name, and the debugger consumes it before any filter or rule runs. It never surfaces as a stop; the name shows up on the thread’s row in the Call Stack instead.

Where rules live

Rules come from up to three files, tried in this order — narrowest scope first, first match wins across the whole chain:

#FileScope
1 <Project>.ExceptionSettings.local.json this developer, this machine — gitignore it
2 <Project>.ExceptionSettings.json the project, shared with its team — commit it
3 %USERPROFILE%\.DelphiWinDebugger\exceptionRules.json every project on this machine

There is no launch.json field for rules — that used to exist and was removed on purpose. “Debug X” and “Attach to X” are the same program started two different ways, and a rule that applied only through one of them was a trap disguised as a feature: nothing about how you connected the debugger has anything to do with which exceptions matter. If a launch configuration still carries an exceptionRules array from before this changed, it is simply not read any more.

Tiers 1 and 2 exist only when a launch or attach configuration names the Delphi project it debugs, through delphiProjectFile — the .dpr, .dpk or (what RAD Studio’s own project services actually report) .dproj path, written automatically by the IDE plugin and resolved by VS Code before the adapter ever sees it. All three spellings of a project name the same pair of files: the adapter derives them from the path’s directory and base name, so Debugme.dproj and Debugme.dpr both point at Debugme.ExceptionSettings.json. This is the scope a Delphi developer actually works in — a .dpk loaded by a host that hosts dozens of other packages carries its own rules, wherever it is loaded from and whoever launched the host.

No delphiProjectFile — a hand-written launch.json, or a plugin older than this field — means no tiers 1 and 2 at all: the machine-wide file is the whole chain, same as it always was. Nothing degrades silently; there is simply nothing to derive a project path from.

Turn tier 3 off per configuration with "useGlobalExceptionRules": false, or point it somewhere else with globalExceptionRulesPath.

All three files are read as strict JSON, not JSONC. A // comment or a trailing comma makes the whole file fail to parse, and the failure is silent: zero rules from that file and no error in the Debug Console. Comments in launch.json are fine — VS Code strips those before the adapter ever sees them — but none of these three files is launch.json.

All three are re-read on every resume: edit any of them while stopped, continue, and the new rules apply without restarting the session. That is worth using deliberately — when something keeps interrupting you, edit the file, press F5, and it is gone, without losing the state you spent twenty minutes reproducing. This even covers creating a sidecar file that did not exist yet mid-session — the adapter notices the file appear, not just change.

Two things that will bite

A catch-all rule in a narrower tier hides everything below it. The three files are evaluated as one ordered list, tier 1 through tier 3 — so a rule with no criteria at all in the local file makes the shared and machine-wide files unreachable, and one in the shared file does the same to the machine-wide file. If a rule you know is in a wider-scoped file stopped firing, look for a catch-all above it first.

A rule with a bad code is dropped, not widened. If the exception code will not parse, the whole rule is discarded rather than treated as “matches any code” — which would silently turn a narrow rule into a catch-all that swallows everything below it. Nothing is reported in the Debug Console, so if a rule seems to have no effect, check its code spelling.

log and logStack write to the Debug Console, not to the debugger’s diagnostics channel. That is the right place — it is your program’s behaviour being reported, not the debugger’s — but it means a noisy log rule shares the pane with your program’s own output.

Stepping out of an exception

Step over, into and out all work while stopped on an exception, and run to the nearest enclosing handler. The debugger finds that handler by deriving it from the binary itself and setting a one-shot breakpoint there — it does not guess.

Where the handler address cannot be proven — which is the case for every try/finally, and for every bare except on a 32-bit target — the step is refused with a stated reason instead. That is deliberate. A guessed handler address would resume the program somewhere arbitrary, and you would have no way to tell.