Your try..except is hiding your bugs from madExcept

I have madExcept wired into everything I ship. It catches the crash, builds a report with the call stack, and mails it to me. It works beautifully, and that is exactly what makes the hole in it so easy to miss.

madExcept only ever sees an exception that nobody caught first. So this:

try
  ...
except
  on E: Exception do
    ShowMessage('Could not load the file');
end;

…quietly switches madExcept off for everything inside that try. Exception is the ancestor of the whole hierarchy. The handler catches the locked file I was actually worried about, and it also catches my EAccessViolation, my off-by-one EListError, and every other bug I wrote in that block. madExcept never sees them. No report, no mail, no stack. The user shrugs at “Could not load the file” and moves on. The bug lives forever, in every copy out there.

So I wrote a Claude Code skill that reads every try..except in a project and asks each one a single question:

If this code failed because of a bug of ours, would we ever find out?

I did not invent the rule it works from, and I should say so. Anders Melander put it on Delphi-PRAXiS years ago, better than I would have: “I would catch a category of errors (permission denied, sharing violation, etc) and let the rest propagate.” And on where the rest should go: “unhandled exceptions are allowed to propagate all the way to the top to be caught by madExcept, and presented to the user as a bug report — because that’s what they are: Bugs.”

Kas Ob., in another thread, gives the test I actually use when a class is on neither of my lists: could a developer have prevented this by writing better code? A dropped connection or a removable drive pulled out — no, so absorb it. Malformed SQL your own code built — yes, so that is your bug and it must reach the reporter.

What nobody has written down is the boring part: going through 135 blocks and deciding which is which. That is the whole reason for the skill.

Four verdicts

Every block gets one:

  • NARROW — catches only classes that really are the outside world’s fault: EStreamError, EInOutError, EOSError. Nothing to do. This is the shape you want.
  • BLINDon E: Exception do, or a bare except. Eats your bugs along with the disk errors. Rewrite it, naming the classes that can actually happen there.
  • SILENT — swallows and does nothing at all. No log, no message, no re-raise. The program carries on in a state nobody understands, and nobody ever learns why.
  • JUSTIFIED — blind on purpose, and the reason is visible. Either a comment says why, or the place itself is the reason: a destructor, an OnCloseQuery, a stdcall callback the Windows API calls back into.

They are tested in that order and the first one that fits wins, because plenty of blocks fit two. A bare except that does nothing is blind and silent at the same time, and without a fixed order two people audit the same block and write down two different answers.

There is a fifth, and it earns its place: UNSURE, with the question that could not be answered written next to it. A skill that never says “I don’t know” is a skill that guesses.

For every BLIND block it also names the exception classes to narrow it to, and writes both versions of the code — what is there now, and what should be there.

It found my own bad code — 92 blocks of it

I ran it on LightSaber, my own library. 135 blocks needed a verdict, 92 came back BLIND, and not one was SILENT — which pleased me more than it should have.

The pleasant surprise was what it cleared. Of the 105 bare except blocks in the library, 43 already look like this:

function LoadSomething(CONST FileName: string): TBitmap;
begin
  Result:= TBitmap.Create;
  TRY
    Result.LoadFromFile(FileName);
  EXCEPT
    FreeAndNil(Result);   { don't leak the half-built bitmap... }
    RAISE;                { ...but madExcept still gets the stack }
  END;
end;

That is the shape you want and I apparently write it by reflex. The FreeAndNil stops the half-built bitmap leaking; the RAISE means a corrupt JPEG and my own access violation do not come back as the same NIL. Drop the RAISE and they do, and the second one disappears forever.

It flagged the file-copy loop I had half-noticed for years:

EXCEPT
  Inc(Result);  { Count failed copies }
END;

Which looks reasonable at three in the morning. It is inside the loop, so one unreadable file does not abort the other ninety-eight — that part is right. But it names no class, so if my own code raises an access violation on file 40, that gets counted as “one more file that failed” and the loop cheerfully continues. And it logs nothing, so when the caller reports “7 files failed” there is no way on earth to find out which seven, or why.

And it taught me something about my own hardware-ID library that I would not have guessed. That library is two units that look almost identical. Both are full of EXCEPT Result:= nil; END;. In one of them every single one of those is correct, and in the other every single one is a bug.

The difference is that chHardID_C.pas is the layer that ships as a DLL — 36 stdcall declarations — and an exception must never cross out of a DLL into C. Swallowing it and returning nil is the only correct thing to do there. chHardID.pas has zero stdcall. It is the implementation, and its eleven near-identical blocks return 'Unknown CPU vendor' and log nothing, so a caller cannot tell a machine that genuinely has no readable BIOS date from my own code raising.

One grep -c "stdcall;" per unit separates them, and that check now runs before any block in a file gets a verdict. I had to be told this by being wrong about it first: the audit’s first draft called all 33 of them findings.

The one that genuinely surprised me

This is the finding I did not expect, and it is worse than any blind catch on the list.

Put a button on a modal form, set its ModalResult to mrOk at design time, and write a save routine in its OnClick. Now let that routine raise. What happens?

You get the madExcept box, you click OK — and the dialog closes and reports success to its caller.

Three lines of VCL source, in the order they run:

  1. TCustomButton.Click in Vcl.StdCtrls.pas assigns Form.ModalResult := ModalResult before it calls inherited Click, which is what fires your OnClick. The form’s result is already set when your handler starts running.
  2. Your handler raises. TWinControl.MainWndProc in Vcl.Controls.pas catches it and calls Application.HandleException — madExcept shows the box and mails the report.
  3. Control goes back to the loop inside TCustomForm.ShowModal in Vcl.Forms.pas, which tests exactly one thing: if ModalResult <> 0 then CloseModal;

So the user sees an error, the dialog shuts anyway, and the calling code reads mrOk for work that never finished. The fix is one line at the top of the handler:

EXCEPT
  ON E: EStreamError DO
    begin
      ModalResult:= mrNone;    { STOP the dialog from closing - it was already mrOk before this ran }
      MessageErrorLog('Could not save:' + CRLF + E.Message);
    end;
END;

Three search bugs, and the third one found 1 block in 105

Worth telling in full, because every one of them will bite anyone who greps Delphi source with habits picked up from C.

Case. The first version searched for except in lower case. Delphi does not care about case, and I write my keywords in capitals — TRY, EXCEPT, ON E: Exception DO. So on LightSaber the search found 62 files where 96 actually contain an except, and it reported no error at all. It just quietly said “all clear” about a third of the codebase.

The handler variable. The blind-catch pattern hard-coded the single letter E. But on E2: Exception do is legal, and so is on Exception do with no variable at all. Both are in my own code. Fixing it took the blind-catch count from 67 to 90.

And then the one that actually mattered. The skill looked for a bare except — one with no on ... do handler at all — by looking for except with end on the very next line. On LightSaber that finds one block. Allowing comment lines in between finds eleven. The correct answer is 105.

Not a near miss. It found under one percent of them. And what it missed was not exotic — it was the FreeAndNil(Result) shape above, the single most ordinary thing in the library.

The reason is worth knowing if you ever write a checker of your own: a bare except is defined by what is not there. Its body can be anything at all — nothing, a comment, FreeAndNil, Result := False, Inc(Result). What makes it bare is that the next real line is not an on ... do. A regular expression cannot say “not followed by” without lookahead, and the tool doing the searching did not have it. So that search is now a small Python script that remembers it is inside an except and looks at the first line that is neither blank nor a comment. Same program also splits the results into “ends in RAISE, fine” and “swallows, needs a verdict” — on LightSaber, 44 against 61.

And then a fourth one, which is the one worth reading

Having fixed the search, I wrote a second little program to sort the 105 bare blocks into “ends in RAISE, fine” and “swallows, needs a verdict”. In awk. It buffered each line into an array and then looked at buf[line+1] to see what came after the except.

Awk streams. It processes one line at a time and had not read line+1 yet. So it was reading whatever happened to be sitting at that index from the previous file. And because FreeAndNil(Result); is the most common line in my library, the stale data looked exactly like real data.

It told me 100 blocks swallowed where 61 do, and it marked 35 perfectly correct RAISE blocks as silent. I wrote that up as the headline finding of the audit — a confident, specific, entirely fictional bug affecting 35 files. Nothing warned me. I only caught it when I went to apply the fix and the first file I opened already had the fix in it.

The lesson is not about awk. It is that a wrong answer with a plausible shape is worse than an error, and that a tool which looks forward through a stream has no way to tell you it is looking at nothing. The classifier is a Python script now, which reads the whole file before it decides anything.

I caught the first two search bugs because I made a second Claude session check the first one’s work, with no memory of having written it. I caught the third by actually running the skill on a real library instead of reasoning about it. I caught the fourth by trying to act on its output. Three different kinds of check, and each one found something the previous kind could not.

What it does not do

Being honest here, because these matter:

  • It reports. It does not change your code unless you explicitly type fix.
  • “Every try..except” means every one in your own non-test source. It skips unit tests and third-party units on purpose — a blind catch in a test is usually deliberate, and somebody else’s source is not yours to narrow — and it ignores the build folders (Output\, __history\, Win32\, Win64\).
  • It cannot tell you whether a specific EConvertError is your fault. StrToInt on something the user typed is bad data and catching it is right; StrToInt on a string your own code built is a bug. The skill flags the block and tells you to look at where the value came from. That call is yours.
  • Four of its eleven rules assume LightSaber, my own library (free, MPL-2.0, on GitHub). They name routines like MessageErrorLog and ForceDirectoriesB. Without LightSaber, skip those four or point them at whatever you use to show an error and write a log line. The other seven need nothing but Delphi.
  • The RTL line numbers are Delphi 13. Check yours before you trust one.
  • It is slow on a big project. Over about 60 blocks it works folder by folder, and it should — each block needs the surrounding procedure read, not just the five lines of the block. LightSaber took one long session.
  • It leaves some blocks marked UNSURE, on purpose. Three of mine are anonymous methods handed to the Android platform layer, and neither of us could answer whether an exception escaping one of those kills the process. A guessed verdict there would have been worse than no verdict.

One thing it can do that reading the source cannot

Everything above is static — it reads your code and reasons about it. madExcept has a switch that lets the running program tell you the answer instead.

An exception that some try..except already handled is what madExcept calls a hidden exception, and you can ask to be told about them:

RegisterHiddenExceptionHandler(MyHandler, stDontSync);

Your handler is called with handled already set to True, so if you do nothing you are only being notified and the program runs on exactly as before. Log the class name, use the app for an hour, then read the log. Every line in it is a block that is absorbing something today. An EAccessViolation or an EListError in that log is a bug of yours that madExcept has never mailed you and never will.

Debug builds only — madShi’s own documentation is blunt about it: “You should not enable this feature by default in a shipping product”, because unlike the rest of madExcept it costs time during normal running, not just when something crashes.

Install it

Drop the folder into C:\Users\<you>\.claude\skills\ and restart Claude Code. Then either type the command, or just say what you want:

/light-review-DelphiExceptions
/light-review-DelphiExceptions c:\MyProject\Source
/light-review-DelphiExceptions c:\MyProject\Source fix

Or in plain words — “audit the exception handling”, “are we swallowing exceptions”, “why did madExcept never report this”. It writes the findings to ExceptionAudit <date>.md in your project root.

The skill: light-review-DelphiExceptions on GitHub

All of them: Claude Tools for Delphi — about thirty skills for Delphi work, all MPL-2.0. Use them, change them, ship them in commercial work.

Leave a Comment

Scroll to Top