Skip to content

fix: harden decompilation for complex CFG and type inference edge cases#2905

Open
PumpkinDemo wants to merge 1 commit into
skylot:masterfrom
PumpkinDemo:dev
Open

fix: harden decompilation for complex CFG and type inference edge cases#2905
PumpkinDemo wants to merge 1 commit into
skylot:masterfrom
PumpkinDemo:dev

Conversation

@PumpkinDemo

@PumpkinDemo PumpkinDemo commented Jul 1, 2026

Copy link
Copy Markdown

Note: The changed code and this PR summary are generated by AI. I encountered many errors when using jadx. But it's too hard for a freshman in decompilation field to fixed them manually. So I ask AI to do it. If AI-generated code is not welcomed by this project, just reject this PR. Or if there is any unsuitable code change, let me know please.

Summary

This PR hardens jadx decompilation for several recoverable edge cases in complex control-flow graphs, region reconstruction, type inference, and late code generation.

Previously, these cases could produce hard decompilation errors such as UnsupportedOperationException, NullPointerException, Unreachable block, type update limit failures, or missing SSA/CodeVar failures. In most of these situations jadx can still produce useful output, so this PR either recovers the structure, safely skips invalid propagation, removes unreachable CFG fragments, or downgrades bounded overflow conditions to warning comments.

Fixed Issues

1. Synthetic switch break appended to immutable region sub-blocks

SwitchBreakVisitor previously appended synthetic break containers by mutating region.getSubBlocks() directly.

Some region implementations expose immutable sub-block lists. For example, a switch case body can be an IfRegion, and IfRegion#getSubBlocks() returns an unmodifiable list.

Example shape:

SwitchRegion
  case 1 -> IfRegion
  case 2 -> Region

If jadx needed to append a synthetic break after case 1, the old code could throw UnsupportedOperationException.

The fix adds a controlled append path:

  • Append directly when the target is a mutable Region.
  • Otherwise wrap the old region and synthetic break in a new Region.
  • Replace the old case container through replaceSubBlock.
  • Add replacement support for SwitchRegion case containers.

2. Switch inside loop selecting the loop back-edge as switch out block

When a switch is inside a loop, a case or default branch can flow back to the loop start.

Example:

do {
    switch (tag) {
        case 0:
            return this;

        default:
            break;
    }
} while (parseUnknownField(tag));

The loop start is a back-edge target, not the switch out block. In some CFG shapes jadx could select the loop start as SwitchRegion.out, producing an invalid region structure.

The fix keeps the current loop context while resolving switch exits and falls back to the immediate post-dominator when the selected out block is actually the loop start.

3. Type propagation re-entering the same SSA assignment

Type propagation can revisit the same SSA assignment through use chains in branch-heavy methods.

Example:

a = a.set(value);
if (a != null) {
    a.mark();
}

Repeated across many switch cases, this can make type update processing re-enter an assignment already handled by the current update intent.

The fix skips re-processing an SSA assignment if the current TypeUpdateInfo already processed it.

4. Type propagation assuming instructions always have result registers

Some later passes assumed instructions still had a result register after previous simplification removed it.

Example IR:

CONST
  args = [literal 0]
  result = null

The fix adds result-null guards in the affected type propagation paths, so these passes skip or safely reject updates when no result register exists.

5. Ternary simplification assuming inlined const instructions always have results

TernaryMod can see wrapped or inlined const instructions whose result register was already removed.

Example IR:

CONST
  args = [literal 1]
  result = null

The fix checks for a missing result before trying to update or reuse the const assignment result.

6. MOVE cleanup assuming result register exists

PrepareForCodeGen removes useless MOVE instructions, but it previously assumed every MOVE still had a result register.

Example IR:

MOVE
  args = [literal 0]
  result = null

The fix treats a MOVE without result as removable instead of failing during cleanup.

7. Check-cast removal conflicting with immutable result type

ModVisitor could remove a CHECK_CAST and force the cast type onto the result register even when that result was marked with IMMUTABLE_TYPE.

Example:

result type = Object
immutable type = Object
cast type = String

The fix rejects the check-cast result type update when it conflicts with an immutable result type.

8. Missing top splitter for exception handlers

Exception-region building expected every handler path to have a top splitter. Some malformed or transformed CFG shapes can contain a handler block without that splitter.

The fix adds a nullable lookup path and lets region building fall back without aborting the whole method when the top splitter is missing.

9. Region construction overflow reported as a hard error

Very complex, irreducible, or state-machine-like CFGs can exceed region construction limits.

Instead of recording a hard error for JadxOverflowException or StackOverflowError, the fix clears the method region and lets MethodGen use fallback instruction output in AUTO mode. A warning comment is still emitted so the
reason is visible.

10. Missing CodeVar or SSA metadata in late passes

Some late-stage IR objects can survive without fully initialized CodeVar or SSA metadata.

Example SSA state:

SSAVar r2v3
type = boolean
codeVar = null

Example catch argument state:

catch arg = RegisterArg
ssaVar = null

The fix initializes missing CodeVar or SSA metadata before late reads in FinishTypeInference and catch-block code generation.

11. Null endpoints in finally traversal cache keys

Finally extraction caches searched block pairs.

Example cache key:

(finallyTerminus, candidateTerminus)
(B3, null)

Pair#hashCode() previously assumed both values were non-null, so using such a pair as a HashMap key caused an NPE.

The fix makes Pair equality and hashing null-safe.

12. Unreachable blocks after CFG rewrites

Later block-tree rewrites can produce new orphan CFG fragments.

Example shape:

enter -> B1 -> B2 -> exit

B100 -> B101

Previously, non-special unreachable blocks found by the final check caused a hard failure:

Unreachable block: B100

The fix reuses the existing unreachable-block removal path instead of throwing, while preserving the existing special handling for split-cross blocks.

13. Type update limit overflow reported as a hard error

Large or highly connected methods can exceed the configured type update limit.

Example error:

Type inference error: updates count limit reached

This limit is a guard against excessive propagation, not necessarily corrupted IR. The method can still often be emitted with partial type information.

The fix downgrades update-limit overflows in both initial type inference and later type fixes to warning comments. Other type inference exceptions are still reported as errors.

Tests

Added or updated regression coverage for:

  • synthetic break insertion around switch regions
  • switch inside loop out-block selection
  • recursive SSA type update propagation
  • type update handling for instructions without result registers
  • ternary handling for const instructions without result registers
  • MOVE cleanup without result registers
  • immutable check-cast result conflicts
  • missing exception handler top splitter
  • region overflow fallback
  • missing CodeVar or SSA metadata recovery
  • null-safe Pair keys
  • unreachable block cleanup after CFG rewrites
  • type update limit handling without hard-error comments

Commands run:

./gradlew --configure-on-demand :jadx-core:test \
  --tests jadx.tests.functional.TestDecompileGuards \
  --tests jadx.tests.functional.TestSwitchRegionMaker \
  --tests jadx.tests.functional.TestTypeUpdate \
  --tests jadx.tests.integration.others.TestBlockProcessorGuards \
  --tests jadx.tests.integration.switches.TestSwitchInLoop10 \
  --tests jadx.tests.integration.types.TestTypeResolver27
git diff --check

Additional local validation was done by running jadx-cli with --show-bad-code --comments-level debug --log-level error on large Android bytecode inputs and confirming that the fixed cases no longer produce hard decompilation errors.

Fix several decompilation failures involving malformed or incomplete
IR states produced by earlier rewrite passes on complex CFGs.

- Switch break insertion now goes through a shared append helper instead
  of mutating getSubBlocks() directly, so regions with unmodifiable
  sub-block views can still be patched. SwitchRegion also supports
  replacing case containers so switch cases can be wrapped safely.
- Type inference stops re-entering an SSA variable whose assign arg was
  already processed in the current update, preventing propagation
  cycles in large loop/switch methods with fluent assignments.
- Switch out calculation treats a dominance-frontier result pointing
  back to the current loop header as loop back-edge noise and uses the
  switch immediate post-dominator instead.
- Allow Pair keys to contain null endpoints so finally path traversal
  can cache partially terminated block pairs without failing during
  hash lookup.
- Remove ordinary unreachable CFG fragments discovered after block tree
  rewrites instead of throwing from BlockProcessor, reusing the
  existing unreachable-block cleanup path.
- Downgrade type update limit overflows in initial type inference and
  follow-up type fixes to warning comments, preserving partial output
  instead of recording a decompilation error.
- Initialize missing code variables before marking anonymous
  constructor capture args as final.
- Keep check-cast instructions when removing them would require
  changing an immutable result type.
- Tolerate MOVE instructions without result registers during codegen
  preparation while preserving wrapped instructions with side effects.
- Allow exception handler region building to degrade when a top
  splitter cannot be found, instead of failing the whole method.
- Fallback to instruction dump output when region restructuring
  overflows on complex CFGs, and guard constructor movement for
  methods without regions.
- TypeUpdate.sameFirstArgListener now stops propagation when the
  counterpart arg is missing, since some CONST instructions can survive
  as literal/wrapped inputs after their result was removed by earlier
  passes.
- TernaryMod now treats a const instruction as inlineable only when it
  still has a result, falling back to the PHI argument otherwise.

Add regression coverage for all of the above: non-mutable switch break
targets, switch case container replacement, loop-header switch out
recovery, the type inference propagation cycle, null Pair keys, orphan
block cleanup in BlockProcessor, type update limit handling, missing
exception splitters, MOVE cleanup without result registers, immutable
cast-result checks, and a CONST instruction without a result in
sameFirstArgListener.
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.

1 participant