diff --git a/Example/Example_Legacy.xcodeproj/xcshareddata/xcschemes/SUI_Example.xcscheme b/Example/Example_Legacy.xcodeproj/xcshareddata/xcschemes/SUI_Example.xcscheme index d577c59f8..71f71508f 100644 --- a/Example/Example_Legacy.xcodeproj/xcshareddata/xcschemes/SUI_Example.xcscheme +++ b/Example/Example_Legacy.xcodeproj/xcshareddata/xcschemes/SUI_Example.xcscheme @@ -50,6 +50,7 @@ Scheme { .scheme( name: name, @@ -358,6 +362,7 @@ func scheme( : nil, runAction: .runAction( configuration: debugConfiguration, + customLLDBInitFile: customLLDBInitFile, executable: .executable(target), arguments: runArguments ), @@ -382,7 +387,8 @@ let schemes: [Scheme] = [ name: "SUI_Example", target: "Example", debugConfiguration: swiftUIDebug, - releaseConfiguration: swiftUIRelease + releaseConfiguration: swiftUIRelease, + customLLDBInitFile: swiftUIDisplayListLLDBInitFile ), scheme( name: "OSUI_HostingExample", @@ -398,7 +404,8 @@ let schemes: [Scheme] = [ debugConfiguration: swiftUIDebug, releaseConfiguration: swiftUIRelease, testableTargets: [], - runArguments: swiftUIHostingLaunchArguments + runArguments: swiftUIHostingLaunchArguments, + customLLDBInitFile: swiftUIDisplayListLLDBInitFile ), scheme( name: "OSUI_TestingHost", @@ -412,7 +419,8 @@ let schemes: [Scheme] = [ target: "TestingHost", debugConfiguration: swiftUIDebug, releaseConfiguration: swiftUIRelease, - includeTestAction: false + includeTestAction: false, + customLLDBInitFile: swiftUIDisplayListLLDBInitFile ), .scheme( name: "OSUI_UITests", diff --git a/Scripts/LLDB/.lldbinit-swiftui b/Scripts/LLDB/.lldbinit-swiftui new file mode 100644 index 000000000..8bdde7d22 --- /dev/null +++ b/Scripts/LLDB/.lldbinit-swiftui @@ -0,0 +1 @@ +command script import --relative-to-command-file swiftui_displaylist_minimal_description.py diff --git a/Scripts/LLDB/README.md b/Scripts/LLDB/README.md new file mode 100644 index 000000000..27a5554cf --- /dev/null +++ b/Scripts/LLDB/README.md @@ -0,0 +1,77 @@ +# SwiftUI Display-List LLDB Commands + +`swiftui_displaylist_minimal_description.py` redirects SwiftUI's full +display-list description to its minimal description while debugging. + +SwiftUI normally checks `SWIFTUI_PRINT_TREE` and then calls `description`. +The two getters use the same Swift calling convention and return the same type: + +```text +$s7SwiftUI11DisplayListV11descriptionSSvg +$s7SwiftUI11DisplayListV18minimalDescriptionSSvg +``` + +While the process is paused, the script replaces the first arm64 instruction +of `description` with a direct branch to `minimalDescription`. SwiftUI retains +its existing environment-variable check, output prefix, and printing schedule; +only the display-list payload changes from the full form to the minimal form. +No breakpoint is required, so Xcode does not stop for each printed tree. + +## Usage + +Enable `SWIFTUI_PRINT_TREE=1` in the Xcode Scheme before launching the app. +SwiftUI caches this setting, so changing it after launch requires a relaunch. + +Run the app until SwiftUICore is loaded, pause it, then import and enable the +hook: + +```text +(lldb) command script import /Scripts/LLDB/swiftui_displaylist_minimal_description.py +(lldb) swiftui-display-list-minimal enable +``` + +The `SUI_Example`, `SUI_HostingExample`, and `SUI_TestingHost` schemes import +the script automatically through the adjacent `.lldbinit-swiftui` file; for +those schemes, only the `enable` command is needed after pausing. + +Continue execution. Tree output now uses `minimalDescription`, for example: + +```text +View 0x... at Time(...): +(DL(I:...)) +``` + +Inspect or remove the hook while the process is paused: + +```text +(lldb) swiftui-display-list-minimal status +(lldb) swiftui-display-list-minimal disable +``` + +`disable` restores the exact instruction bytes that were present before +`enable`. A process restart also restores the original SwiftUICore mapping. + +## Existing description breakpoints + +Xcode can persist script-created breakpoints while dropping their Python +actions. Such a breakpoint stops in `DisplayList.description` and then executes +the full getter when continued. + +When enabling the hook, the script temporarily disables any breakpoint location +already resolved at the `description` entry. It restores those locations when +the hook is disabled. A stale symbolic breakpoint can also be removed once from +Xcode's Breakpoint navigator. + +## Scope + +- The script has been validated against SwiftUI 6.5.4 on an arm64 iOS 18.5 + Simulator. +- The hook currently supports arm64 and arm64e targets. It validates instruction + alignment and the direct-branch range before changing memory. +- The getters are private implementation details and may change between OS + releases. Missing or ambiguous symbols leave the process unchanged. +- While enabled, every direct call to `DisplayList.description` is redirected, + not only calls originating from `SWIFTUI_PRINT_TREE`. +- The change exists only in the debugged process. It does not modify the + SwiftUICore binary on disk. +- Disable the hook before detaching LLDB if the process will remain alive. diff --git a/Scripts/LLDB/swiftui_displaylist_minimal_description.py b/Scripts/LLDB/swiftui_displaylist_minimal_description.py new file mode 100644 index 000000000..461ba43fb --- /dev/null +++ b/Scripts/LLDB/swiftui_displaylist_minimal_description.py @@ -0,0 +1,513 @@ +"""LLDB command for printing SwiftUI display lists minimally. + +SwiftUI's SWIFTUI_PRINT_TREE path calls DisplayList.description. While the +inferior is stopped, this command temporarily replaces that getter's first +arm64 instruction with a direct branch to DisplayList.minimalDescription. +Both getters have the same Swift calling convention, so SwiftUI keeps its +original environment gating, prefix, and printing schedule. +""" + +import lldb +import shlex +import struct + + +_COMMAND_NAME = "swiftui-display-list-minimal" +_DESCRIPTION_SYMBOL = "$s7SwiftUI11DisplayListV11descriptionSSvg" +_MINIMAL_DESCRIPTION_SYMBOL = ( + "$s7SwiftUI11DisplayListV18minimalDescriptionSSvg" +) +_SWIFTUI_CORE_MODULE = "SwiftUICore" +_PRINT_TREE_ENVIRONMENT_KEY = "SWIFTUI_PRINT_TREE" +_ARM64_BRANCH_OPCODE = 0x14000000 +_ARM64_BRANCH_IMMEDIATE_MASK = 0x03FFFFFF +_INSTRUCTION_SIZE = 4 +_GONE_PROCESS_STATES = { + lldb.eStateInvalid, + lldb.eStateUnloaded, + lldb.eStateExited, +} +_WRITABLE_PROCESS_STATES = { + lldb.eStateStopped, + lldb.eStateCrashed, + lldb.eStateSuspended, +} + +# Preserve the patch across `command script import` reloads in one LLDB +# session. A process restart restores the framework mapping by itself. +if "_PATCH_STATE" not in globals(): + _PATCH_STATE = None + + +class HookError(RuntimeError): + pass + + +def _selected_target(debugger): + target = debugger.GetSelectedTarget() + if not target.IsValid(): + raise HookError("No LLDB target is selected.") + return target + + +def _stopped_process(target): + process = target.GetProcess() + if not process.IsValid() or process.GetProcessID() == 0: + raise HookError( + "Launch the process and pause it before enabling the hook." + ) + if process.GetState() != lldb.eStateStopped: + raise HookError("Pause the process before changing the hook.") + return process + + +def _symbol_load_addresses(module, target, name): + contexts = module.FindSymbols(name, lldb.eSymbolTypeCode) + addresses = set() + for index in range(contexts.GetSize()): + symbol = contexts.GetContextAtIndex(index).GetSymbol() + if not symbol.IsValid(): + continue + address = symbol.GetStartAddress().GetLoadAddress(target) + if address != lldb.LLDB_INVALID_ADDRESS: + addresses.add(address) + return addresses + + +def _branch_instruction(source, destination): + if source == destination: + raise HookError("The two DisplayList getters resolve to one address.") + + delta = destination - source + if delta % _INSTRUCTION_SIZE != 0: + raise HookError("Getter addresses are not instruction-aligned.") + + immediate = delta // _INSTRUCTION_SIZE + if immediate < -(1 << 25) or immediate >= (1 << 25): + raise HookError( + "DisplayList.minimalDescription is outside arm64 branch range." + ) + instruction = ( + _ARM64_BRANCH_OPCODE + | (immediate & _ARM64_BRANCH_IMMEDIATE_MASK) + ) + return struct.pack("" + return "" + + +def _new_patch_state( + target, + process, + source, + destination, + original, + instruction, + locations, + uncertain=False, +): + return { + "target": target, + "process": process, + "process_id": process.GetProcessID(), + "process_unique_id": process.GetUniqueID(), + "source": source, + "destination": destination, + "original": original, + "instruction": instruction, + "locations": locations, + "uncertain": uncertain, + } + + +def _same_process(state, process): + return state["process_unique_id"] == process.GetUniqueID() + + +def _retire_ended_patch(): + global _PATCH_STATE + + if _PATCH_STATE is None: + return + process = _PATCH_STATE["process"] + state = process.GetState() if process.IsValid() else lldb.eStateInvalid + if state in _GONE_PROCESS_STATES: + _restore_breakpoints(_PATCH_STATE["locations"]) + _PATCH_STATE = None + + +def _prepare_enable(target, process, source, instruction): + """Return True when this process already contains the active patch.""" + global _PATCH_STATE + + _retire_ended_patch() + if _PATCH_STATE is None: + return False + if not _same_process(_PATCH_STATE, process): + raise HookError( + "The hook is still active in process {}. Disable it before " + "enabling another process.".format(_PATCH_STATE["process_id"]) + ) + if ( + _PATCH_STATE["source"] != source + or _PATCH_STATE["instruction"] != instruction + ): + raise HookError( + "The active hook no longer matches the resolved SwiftUICore " + "symbols. Disable it before continuing." + ) + + current = _read_instruction(process, source) + if current == instruction: + _PATCH_STATE["locations"].extend( + _disable_conflicting_breakpoints(target, source) + ) + return True + if current != _PATCH_STATE["original"]: + raise HookError( + "DisplayList.description changed after the hook was enabled." + ) + + # Someone restored the instruction independently. Release our ownership of + # the breakpoints before installing a fresh patch. + _restore_breakpoints(_PATCH_STATE["locations"]) + _PATCH_STATE = None + return False + + +def _enable(debugger, result): + global _PATCH_STATE + + target = _selected_target(debugger) + process = _stopped_process(target) + source, destination, instruction = _resolve_patch(target) + if _prepare_enable(target, process, source, instruction): + result.AppendMessage( + "SwiftUI minimal display-list printing is already enabled." + ) + return + + locations = _disable_conflicting_breakpoints(target, source) + original = None + write_started = False + try: + original = _read_instruction(process, source) + if original == instruction: + raise HookError( + "DisplayList.description is already patched, but its " + "original instruction is unavailable; restart the process " + "to reset it." + ) + write_started = True + _write_instruction(process, source, instruction) + except HookError as error: + if write_started: + try: + _write_instruction(process, source, original) + except HookError as rollback_error: + _PATCH_STATE = _new_patch_state( + target, + process, + source, + destination, + original, + instruction, + locations, + uncertain=True, + ) + raise HookError( + "{} Rollback also failed: {} The instruction may be " + "partially modified; keep the process paused and run " + "`{} disable` again.".format( + error, + rollback_error, + _COMMAND_NAME, + ) + ) + _restore_breakpoints(locations) + raise + + _PATCH_STATE = _new_patch_state( + target, + process, + source, + destination, + original, + instruction, + locations, + ) + result.AppendMessage( + "Enabled SwiftUI minimal display-list printing: {:#x} -> {:#x}.".format( + source, + destination, + ) + ) + if locations: + result.AppendMessage( + "Disabled {} conflicting description breakpoint(s) for this " + "session.".format(len(locations)) + ) + if _print_tree_environment(target) == "": + result.AppendMessage( + "Note: set SWIFTUI_PRINT_TREE=1 in the Scheme before launching " + "the process." + ) + + +def _disable(result): + global _PATCH_STATE + + if _PATCH_STATE is None: + result.AppendMessage("SwiftUI minimal display-list printing is disabled.") + return + + state = _PATCH_STATE + process = state["process"] + process_state = ( + process.GetState() if process.IsValid() else lldb.eStateInvalid + ) + if process_state == lldb.eStateDetached: + raise HookError( + "Process {} is detached and may still contain the patch. " + "This LLDB session can no longer restore it; terminate the " + "process to discard the process-local change.".format( + state["process_id"] + ) + ) + if process_state not in _GONE_PROCESS_STATES: + if process_state not in _WRITABLE_PROCESS_STATES: + raise HookError( + "Pause process {} before disabling the hook.".format( + state["process_id"] + ) + ) + + current = _read_instruction(process, state["source"]) + if current != state["original"]: + if current != state["instruction"] and not state["uncertain"]: + raise HookError( + "DisplayList.description contains an unrelated " + "instruction; refusing to overwrite it." + ) + _write_instruction( + process, + state["source"], + state["original"], + ) + + _restore_breakpoints(state["locations"]) + _PATCH_STATE = None + result.AppendMessage("Disabled SwiftUI minimal display-list printing.") + + +def _patch_status(state): + process = state["process"] + process_state = ( + process.GetState() if process.IsValid() else lldb.eStateInvalid + ) + if process_state in _GONE_PROCESS_STATES: + return "disabled (process ended; breakpoint cleanup pending)" + if process_state == lldb.eStateDetached: + return "enabled (process detached; restoration pending)" + if process_state not in _WRITABLE_PROCESS_STATES: + return "enabled (unverified while process is running)" + + try: + current = _read_instruction(process, state["source"]) + except HookError as error: + return "unknown ({})".format(error) + if current == state["instruction"]: + return "enabled" + if current == state["original"]: + return "disabled externally (breakpoint cleanup pending)" + return "unknown (description instruction changed externally)" + + +def _status(debugger, result): + target = _selected_target(debugger) + state = _PATCH_STATE + status = "disabled" if state is None else _patch_status(state) + result.AppendMessage( + "SwiftUI minimal display-list printing: {}".format(status) + ) + result.AppendMessage("mode=arm64 branch patch (no breakpoint)") + + reporting_target = state["target"] if state is not None else target + result.AppendMessage("target={}".format(reporting_target.GetTriple())) + result.AppendMessage( + "{}={}".format( + _PRINT_TREE_ENVIRONMENT_KEY, + _print_tree_environment(reporting_target), + ) + ) + if state is not None: + result.AppendMessage( + "process={}, description={:#x}, minimalDescription={:#x}".format( + state["process_id"], + state["source"], + state["destination"], + ) + ) + result.AppendMessage( + "conflicting_breakpoints_disabled={}".format( + len(state["locations"]) + ) + ) + + +def swiftui_display_list_minimal( + debugger, + command, + result, + internal_dict, +): + del internal_dict + + try: + arguments = shlex.split(command) + if len(arguments) != 1 or arguments[0] not in { + "enable", + "disable", + "status", + }: + raise HookError( + "usage: {} enable|disable|status".format(_COMMAND_NAME) + ) + + action = arguments[0] + if action == "enable": + _enable(debugger, result) + elif action == "disable": + _disable(result) + else: + _status(debugger, result) + except (HookError, ValueError) as error: + result.SetError(str(error)) + + +def __lldb_init_module(debugger, internal_dict): + del internal_dict + + debugger.HandleCommand( + 'command script add --overwrite -h "Redirect SwiftUI display-list ' + 'tree output to minimalDescription without a breakpoint." -f ' + '{}.swiftui_display_list_minimal {}'.format( + __name__, + _COMMAND_NAME, + ) + ) + print( + "Installed LLDB command: {} enable|disable|status".format( + _COMMAND_NAME + ) + )