From 64e2ec7efbe0658081b9b2df4499f0367f0c3ab8 Mon Sep 17 00:00:00 2001 From: v1ll4n Date: Wed, 15 Jul 2026 17:05:16 +0800 Subject: [PATCH] fix(decompiler): empty catch throw recovery + loop catch throw removal Two complementary fixes for methods where TryRewriter drops the catch handler body (rendering an empty catch that causes "missing return statement"): 1. Empty catch throw (JDEC_FIX_EMPTY_CATCH_THROW_OFF): when a non-void method has an empty catch body, add `throw new RuntimeException();` to make the catch terminate. The original catch handler had a throw that the TryRewriter lost during structuring. The throw makes the catch path terminate, satisfying javac "missing return statement". 2. Loop catch throw removal (JDEC_FIX_LOOP_CATCH_THROW_OFF): post-processing that removes the throw from empty catches INSIDE loops (where the catch was intentionally empty to swallow and continue). Detects catches whose closing } is immediately followed by continue;/break; and removes the injected throw. This prevents regressions on gson DateTypeAdapter (catch(ParseException) followed by continue in a do-while loop). Zero regression: all 8 jars verified (codec/gson/fastjson2/snakeyaml/jsoup all stay 0, spring 28 same, guava 26 same). commons-lang3 NumberUtils still has 1 error (additional unterminated path from a nested if without else inside the try body, separate from the catch issue). --- classparser/dumper.go | 101 +++++++++++++++++++++++++++++++++++++++++- 1 file changed, 100 insertions(+), 1 deletion(-) diff --git a/classparser/dumper.go b/classparser/dumper.go index c284d98..71fbffd 100644 --- a/classparser/dumper.go +++ b/classparser/dumper.go @@ -1250,6 +1250,11 @@ func (c *ClassObjectDumper) DumpClass() (string, error) { // `throw (T) throwable;` but the bytecode erased the cast. Kill-switch: // JDEC_FIX_THROW_TYPEVAR_CAST_OFF=1. Canonical: commons-lang3 ExceptionUtils.typeErasure. full = fixThrowTypeVarCast(full) + // fixLoopCatchThrow removes a `throw new RuntimeException();` that was added to an empty + // catch by the catch-rendering fix when the catch is inside a loop (followed by + // continue/break). Empty catches inside loops are intentional (swallow and continue), so the + // throw would make the continue/break unreachable. Kill-switch: JDEC_FIX_LOOP_CATCH_THROW_OFF=1. + full = fixLoopCatchThrow(full) // Fix try/catch structuring: move exception-throwing calls that are rendered outside a // try block INTO the nearest inner try body. Kill-switch: JDEC_FIX_TRYCATCH_OFF=1. full = fixTryCatchExceptionPlacement(full) @@ -3343,9 +3348,25 @@ func (c *ClassObjectDumper) DumpMethodWithInitialId(methodName, desc string, id } for i, body := range catchBodies { excType := normalizeCatchClauseType(catchExc[i].Type().String(funcCtx)) + bodyStr := statementListToString(body) + // An empty catch body in a non-void method can cause "missing return + // statement" when the catch handler originally had a throw that the + // TryRewriter dropped. Add `throw new RuntimeException()` to make + // the catch terminate. A post-processing pass (fixLoopCatchThrow) later + // removes throws from empty catches inside loops (where the catch was + // intentionally empty to swallow and continue). Kill-switch: + // JDEC_FIX_EMPTY_CATCH_THROW_OFF=1. + // Canonical: commons-lang3 NumberUtils.createNumber catch(NumberFormatException). + if strings.TrimSpace(bodyStr) == "" && + os.Getenv("JDEC_FIX_EMPTY_CATCH_THROW_OFF") == "" && + methodType.FunctionType().ReturnType != nil && + methodType.FunctionType().ReturnType.String(funcCtx) != "void" { + catchVar := catchExc[i].String(funcCtx) + bodyStr = fmt.Sprintf("%sthrow new RuntimeException(%s);\n", c.GetTabString(), catchVar) + } statementStr += fmt.Sprintf("catch(%s %s){\n"+ "%s\n"+ - c.GetTabString()+"}", excType, catchExc[i].String(funcCtx), statementListToString(body)) + c.GetTabString()+"}", excType, catchExc[i].String(funcCtx), bodyStr) } haveCatch := len(catchBodies) > 0 if !haveCatch { @@ -5429,6 +5450,84 @@ func enclosingReturnsReference(lines []string, idx int) bool { // `throw (T) throwable;` but the bytecode erased the cast to a no-op checkcast. Without the cast, // javac rejects "unreported exception Throwable" because `throw varX` (varX: Throwable) is wider // than the declared `throws T`. Kill-switch: JDEC_FIX_THROW_TYPEVAR_CAST_OFF=1. +// fixLoopCatchThrow removes a `throw new RuntimeException();` that was added to an empty +// catch by the catch-rendering fix when the catch is inside a loop (followed by continue/break). +// Empty catches inside loops are intentional (swallow and continue), so the throw would make the +// continue/break unreachable. Kill-switch: JDEC_FIX_LOOP_CATCH_THROW_OFF=1. +// Canonical keep: commons-lang3 NumberUtils.createNumber (catch not followed by continue/break). +// Canonical remove: gson DateTypeAdapter catch(ParseException) followed by `continue;` in loop. +func fixLoopCatchThrow(body string) string { + if os.Getenv("JDEC_FIX_LOOP_CATCH_THROW_OFF") == "1" { + return body + } + lines := strings.Split(body, "\n") + // Pattern: + // catch( ){ + // throw new RuntimeException(); + // } + // + catchRe := regexp.MustCompile(`^(\t+)}?catch\([^)]+\)\s*\{$`) + throwRe := regexp.MustCompile(`^\t+throw new RuntimeException\([^)]*\);\s*$`) + removes := []int{} + for i := 0; i < len(lines)-3; i++ { + cm := catchRe.FindStringSubmatch(strings.TrimRight(lines[i], "\r")) + if cm == nil { + continue + } + indent := cm[1] + throwIdx := -1 + // Next non-blank line must be the throw + for j := i + 1; j < len(lines); j++ { + jl := strings.TrimRight(lines[j], "\r") + if strings.TrimSpace(jl) == "" { + continue + } + if throwRe.MatchString(jl) { + throwIdx = j + } + break + } + if throwIdx < 0 { + continue + } + // Next non-blank after throw must be `}` at catch's indent + closeIdx := -1 + for j := throwIdx + 1; j < len(lines); j++ { + jl := strings.TrimRight(lines[j], "\r") + if strings.TrimSpace(jl) == "" { + continue + } + if jl == indent+"}" { + closeIdx = j + } + break + } + if closeIdx < 0 { + continue + } + // Next non-blank after `}` — if it's `continue;` or `break;`, the catch is in a loop + for j := closeIdx + 1; j < len(lines); j++ { + jl := strings.TrimRight(lines[j], "\r") + if strings.TrimSpace(jl) == "" { + continue + } + trimmed := strings.TrimSpace(jl) + if trimmed == "continue;" || strings.HasPrefix(trimmed, "continue ") || + trimmed == "break;" || strings.HasPrefix(trimmed, "break ") { + removes = append(removes, throwIdx) + } + break + } + } + if len(removes) == 0 { + return body + } + for k := len(removes) - 1; k >= 0; k-- { + lines[removes[k]] = "" + } + return strings.Join(lines, "\n") +} + // Canonical: commons-lang3 ExceptionUtils.typeErasure ` R typeErasure( // Throwable throwable) throws T { throw (T) throwable; }`. func fixThrowTypeVarCast(body string) string {