Skip to content

fix(core): run abort-surviving cleanup in a catch-free finally; guard the pattern - #4337

Merged
bpamiri merged 3 commits into
developfrom
peter/boxlang-finally-catch-guard
Oct 5, 2026
Merged

bpamiri merged 3 commits into
developfrom
peter/boxlang-finally-catch-guard

Conversation

@bpamiri

@bpamiri bpamiri commented Oct 5, 2026

Copy link
Copy Markdown
Collaborator

Hardens two try/catch/finally blocks whose cleanup can be skipped on a BoxLang abort, and adds a structural guard so the pattern can't come back.

Background

On BoxLang, a request that ends with abort skips any finally whose try has a catch clause — even an unreached catch (any) { rethrow; }. A catch-free try/finally runs, and the transaction-action inside it makes no difference; the catch clause alone decides. Lucee and Adobe run the finally either way. (CLAUDE.md Cross-Engine Invariant 22, established by #4330 and #4335.)

So any framework cleanup that must run even when app code (an action, a callback, a mailer render, …) ends the request with an abort has to sit in a finally whose try carries no catch. The safe shape when a catch is also needed is to nest: an outer catch-free try { … } finally { <cleanup> } wrapping an inner try { … } catch { … }.

Changes

Seeder.generateSeeds() and LocalDisk.$assertSymlinkResolutionAvailable() each used a single try/catch/finally. Both are nested so the cleanup — Seeder restoring request.$wheelsTransactionWrapper, LocalDisk deleting the probe temp dir — runs on every engine on abort, keeping the existing catch on the inner try. Normal-path behavior is unchanged.

TryCatchFinallyGuardSpec — a structural scan (modeled on BareCfabortGuardSpec) that fails if any production framework source (*.cfc / *.cfm under vendor/wheels, excluding /tests/ and /wheelstest/, since test-harness code is not a production request path) has a single try with BOTH a catch and a finally. New code that needs both must nest. A brace-depth parser associates each catch/finally with its owning try, so a correctly nested outer-finally / inner-catch (e.g. $invokeTransactionMethod from #4335) is not flagged.

Allowlist: vendor/wheels/model/locking.cfm — $advisoryLockTransactionBody's outer try currently has both a catch and a finally, but its lock release already runs in an inner catch-free finally (#4330) and the abort rollback is now handled by $invokeTransactionMethod (#4335). This entry becomes unnecessary once that outer try is made catch-free in a follow-up, and will be removed then.

Validation

  • Guard: green on Lucee 7, BoxLang, and Adobe 2025. Confirmed RED on the pre-fix tree (it flagged Seeder.cfc:419 and LocalDisk.cfc:305), and it does not flag the safe nested $invokeTransactionMethod shape.
  • Nests behavior-preserving: seederSpec 66/66 and storage.StorageSpec 50/50 on Lucee 7.

No changelog fragment: normal-path behavior is unchanged (the nests only matter on a BoxLang abort during a CLI seed or storage init-probe), and the guard is a test.

🤖 Generated with Claude Code

bpamiri and others added 2 commits October 5, 2026 04:43
…guard the pattern

BoxLang skips a finally whose try has a catch clause when the request ends with
an abort, so cleanup in a try/catch/finally wrapped around app code can be lost
(see the BoxLang finally/catch rule in CLAUDE.md Cross-Engine Invariants).

Seeder.generateSeeds() (restores request.$wheelsTransactionWrapper) and
LocalDisk.$assertSymlinkResolutionAvailable() (deletes the probe temp dir) both
used a single try/catch/finally. Nest each so the cleanup lives in an outer
catch-free try/finally that runs on every engine on abort, keeping the existing
catch on the inner try.

Add TryCatchFinallyGuardSpec: a structural scan that fails if any production
vendor/wheels source (*.cfc / *.cfm, excluding /tests/ and /wheelstest/) has a
single try with BOTH a catch and a finally. New code that needs both must nest.
withAdvisoryLock's transaction-path outer try is allowlisted, because its lock
release already runs in an inner catch-free finally.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: Peter Amiri <peter@alurium.com>
@bpamiri

bpamiri commented Oct 5, 2026

Copy link
Copy Markdown
Collaborator Author

rev1-r3 review @ 8bb1a7f: code APPROVE (final sign-off after matrix run 37307996108 and PR CI); one parser-robustness note

The two fixes keep behaviour the same.

  • Seeder.generateSeeds() only gains an outer try { and a closing }. The inner try/catch (commit or rollback, result.success = false, return result) is byte-identical. The return from the inner catch now leaves through the outer finally, which restores request.$wheelsTransactionWrapper as before. (The inner block wasn't re-indented, which is cosmetic.)
  • LocalDisk.$assertSymlinkResolutionAvailable(). The probe body and its catch (any e) { probe.resolved = false; } move unchanged into an inner try. The symlink-first cleanup stays in the outer, catch-free finally.

The guard: I checked it against an independent parser. I ported its scan to Python line for line: the same comment/string blanking, keyword regexes and brace-depth frame stack. I compared it with a reference parser that brace-matches each try { … } over the whole cleaned file and walks its catch/finally chain.

  • On this head: both report exactly model/locking.cfm:289, the allowlisted case. So there are no false positives and no misses in today's source, including the safe nested shapes ($invokeTransactionMethod, the two new nests).
  • On develop (before the fix): both report Seeder.cfc:419, LocalDisk.cfc:305 and locking.cfm:289, which matches your RED.
  • Comments and strings: these are blanked by a single-pass state machine, not a global regex, which matches invariant 14 and the Lucee 7 regex-hang memo. Braces and keywords inside "…" and /* … */ don't count, which I checked with synthetic cases.
  • DirectoryList() is copied into a fresh array (invariant 20).
  • The exclusion is FindNoCase("/tests/" | "/wheelstest/", rel). Besides vendor/wheels/tests/** and wheelstest/**, that also skips vendor/wheels/public/tests/*.cfm, the test-runner output views. That's reasonable, since they're test-runner pages, not app request paths.

Not blocking: shapes the line-based scan gets wrong. None of these occur in today's tree (I scanned for each), but a future edit could hit them:

Shape Guard Reference
} on its own line, then catch (…) { / finally { on the following lines (Allman style) miss offender
} catch (any e) { x(); } on one line, then finally { on the next miss offender
try { a(); } catch (e) { b(); } finally { c(); } on one line miss offender
A one-line inner try { a(); } catch (e) {} inside an outer try { … } finally { … } (the safe nest) false positive clean

The cause is the same in each case. The scan attributes a catch to the top frame before it pushes that line's try, and it pops a frame as soon as a line brings the depth back down. So when a catch or finally doesn't share a line with the closing } of the previous block, it gets attached to the wrong try.

The robust form is to brace-match over the whole cleaned file instead of line by line. After clean, join the lines, find each try\s*\{, scan to its matching }, then repeatedly match \s*(catch\s*\([^)]*\)|finally)\s*\{ and skip each block. It's about 20 lines with no regex over the whole file. A tiny self-test with the four synthetic shapes above would pin it. That's fine as a follow-up (it could ride with the PR that removes the locking.cfm allowlist entry), or now, at orch1's call.

Tag-based <cftry>/<cfcatch>/<cffinally> and <!--- … ---> tag comments aren't scanned either. There are no <cffinally> blocks in production vendor/wheels today, so a docblock line saying the guard covers script syntax only would be enough.

…atcher with a self-test

The line-based frame scan had blind spots (rev1-r3): it missed Allman-style
own-line catch/finally, a same-line catch body followed by a next-line finally,
and a one-line try/catch/finally, and it false-positived a one-line inner
try/catch nested inside a safe outer try/finally. None occur in the tree today,
but a future edit could hit them.

Replace it with a whole-file brace matcher: blank comments and string literals
to spaces in a single char pass (preserving length and newlines, no global
regex — Lucee 7 hang), then for each `try {` brace-match its body and walk the
following catch/finally chain. A try is an offender iff its own chain has BOTH a
catch and a finally, so a nested outer-finally / inner-catch is correctly clean.

Add a self-test that runs the scanner over the four wrong shapes plus the
catch-free, nested-one-line, nested-multiline and try/catch-only clean shapes.
Document that the scan covers script syntax only (the cf-tag try form is not
scanned; the angle-bracket syntax is kept out of comments because Lucee's tag
scanner parses a literal cf-tag even inside one).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: Peter Amiri <peter@alurium.com>
@bpamiri

bpamiri commented Oct 5, 2026

Copy link
Copy Markdown
Collaborator Author

rev1-r3 re-review @ d45a4fc: code APPROVE (final sign-off after matrix run 37309270818 and PR CI)

The delta 8bb1a7fe2..d45a4fc6f is the spec only, and it replaces the line-based frame scan as suggested:

  • $blankCommentsAndStrings() blanks comments and strings in a single character pass, keeping the length and newlines, so positions and line numbers stay exact. It uses no global regex, which avoids the Lucee 7 hang.
  • $scanTryCatchFinally() brace-matches over the whole file. For each try { it finds the matching } with $matchBrace(), then walks the catch (…) {} / finally {} chain, and stops after finally. A try counts as an offender only if its own chain has both. It then moves just past that try's {, so nested trys are classified on their own.
  • The self-test checks the four shapes I listed plus the plain multi-line case as offenders. It checks four shapes as clean: catch-free try/finally, a one-line inner try/catch inside a nest, a multi-line nest, and try/catch with no finally. Because it calls the scanner directly, an engine where some string or regex call behaves differently would fail it instead of passing an empty scan. That's what makes the green on Lucee 7, BoxLang and Adobe 2025 meaningful.

Cross-check, rerun. I ported the new scanner to Python one-to-one and ran it on the source trees:

  • This head: only model/locking.cfm (the allowlisted $advisoryLockTransactionBody outer try).
  • develop: Seeder.cfc, storage/drivers/LocalDisk.cfc and model/locking.cfm.

These are the same three files as my reference brace-matcher found. The line numbers now point at the offending try instead of its finally, which is easier to act on. The docblock now says the scan covers script syntax only.

@github-actions

github-actions Bot commented Oct 5, 2026

Copy link
Copy Markdown
Contributor

Wheels Test Results

222 580 tests   217 459 ✅  1h 26m 10s ⏱️
 17 312 suites    5 121 💤
     32 files          0 ❌

Results for commit d45a4fc.

@bpamiri

bpamiri commented Oct 5, 2026

Copy link
Copy Markdown
Collaborator Author

rev1-r3 review @ d45a4fc: APPROVE (final; matrix run 37309270818 read leg by leg, PR CI checked)

  • Matrix run 37309270818 is on this head, and all 32 legs plus rustcfml succeeded. From the junit artifact for each leg:
    • 6950–6967 tests per leg, with 0 failures and 0 errors on every leg.
    • TryCatchFinallyGuardSpec ran and passed 2/2 on every leg, none skipped: the production scan and the scanner self-test. That includes all six Adobe 2023 and all six Adobe 2025 legs and BoxLang, so the scanner finds every offender shape on every engine.
    • The behaviour-preservation specs for the two nests pass on every leg: seederSpec 66/66 and StorageSpec 50/50.
  • PR CI on this head: 19 pass, 2 skipped, 0 failing or pending.

@bpamiri
bpamiri merged commit 179125c into develop Oct 5, 2026
59 checks passed
@bpamiri
bpamiri deleted the peter/boxlang-finally-catch-guard branch October 5, 2026 12:46
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant