From 67794a0557fb96e83963654920e343d9789f287f Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 15 Jul 2026 23:04:37 +0000 Subject: [PATCH] Fix keep-alive crash on non-JIT apps (e.g. Roblox) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Keep-alive ran the universal JIT breakpoint-handler script against every app on TXM devices. That script is meant for apps that use the brk #0xf00d JIT protocol (emulators). Against a large, multi-threaded, non-JIT app like Roblox it chases every signal the app raises — logging verbosely each iteration — which: - grows RunJSViewModel.logs (a @Published array rendered in a SwiftUI List) without bound until StikDebug runs out of memory and crashes, and - mishandles the app's many threads (single-thread vCont), wedging it. Two fixes: - keepAliveScript now returns a script only for user-assigned or name-matched (known JIT) apps. Everything else is held with a plain debugger attach, which keeps the app alive in the background without interfering with its threads or signals. This restores the proven plain-hold path for apps like Roblox. - appendLog caps the log buffer (with amortized trimming) so a long-running or spinning hold script can never exhaust memory. The user can still stop such a hold via should_continue(). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01EpCxAimrBo9RA8v6BxSEXT --- StikDebug/JSSupport/RunJSView.swift | 24 +++++++++++++++---- StikDebug/Support/ScriptStore.swift | 37 +++++++++-------------------- 2 files changed, 30 insertions(+), 31 deletions(-) diff --git a/StikDebug/JSSupport/RunJSView.swift b/StikDebug/JSSupport/RunJSView.swift index 5c776e4..df98a48 100644 --- a/StikDebug/JSSupport/RunJSView.swift +++ b/StikDebug/JSSupport/RunJSView.swift @@ -69,7 +69,7 @@ final class RunJSViewModel: ObservableObject, Identifiable, @unchecked Sendable let logFunction: @convention(block) (String) -> Void = { logStr in DispatchQueue.main.async { - self.logs.append(logStr) + self.appendLog(logStr) } } @@ -111,13 +111,27 @@ final class RunJSViewModel: ObservableObject, Identifiable, @unchecked Sendable DispatchQueue.main.async { if let exception = self.context?.exception { - self.logs.append(exception.debugDescription) + self.appendLog(exception.debugDescription) } - self.logs.append("Script Execution Completed") - self.logs.append("You are safe to close this window.") + self.appendLog("Script Execution Completed") + self.appendLog("You are safe to close this window.") } } - + + /// Appends a log line, keeping the buffer bounded. A hold-mode script (e.g. + /// universal.js) can run indefinitely and log on every iteration; without a + /// cap the `@Published` array — and the SwiftUI list bound to it — grows + /// without bound and eventually exhausts memory. Must run on the main thread. + private func appendLog(_ entry: String) { + logs.append(entry) + if logs.count > Self.maxLogEntries + Self.logTrimSlack { + logs.removeFirst(logs.count - Self.maxLogEntries) + } + } + + private static let maxLogEntries = 1000 + private static let logTrimSlack = 256 + private func captureScreenshot(named preferredName: String?) -> String { if executionInterrupted { raiseException("Script execution is interrupted by StikDebug.") diff --git a/StikDebug/Support/ScriptStore.swift b/StikDebug/Support/ScriptStore.swift index 3f1b9ea..0a8fd93 100644 --- a/StikDebug/Support/ScriptStore.swift +++ b/StikDebug/Support/ScriptStore.swift @@ -72,15 +72,17 @@ enum ScriptStore { assignedScript(for: bundleID, fileManager: fileManager) ?? autoScript(for: bundleID, fileManager: fileManager) } - /// The JIT script to run when holding an app alive in the background. + /// The JIT script to run while holding an app alive in the background, or + /// `nil` to hold the app with a plain debugger attach. /// - /// On a TXM device (iOS 26+) JIT is serviced by handling `brk #0xf00d` - /// syscalls, so an app that is merely attached — with no script servicing - /// those traps — hangs the moment it requests executable memory. That is - /// why keep-alive "didn't work for any app": only apps whose name/bundle - /// matched an assigned or auto script got a callback. Fall back to the - /// bundled universal script so *any* app has its JIT traps serviced (and, - /// for non-JIT apps, its debug connection actively held) while alive. + /// Only apps with a user-assigned or name-matched (known JIT app) script get + /// a script. Those apps request executable memory via `brk #0xf00d` syscalls + /// that the script must service, so without it they hang. Apps that do *not* + /// use JIT — e.g. Roblox and most games — are deliberately held with a plain + /// attach instead: running the JIT breakpoint handler against them makes it + /// chase every signal the app raises across all of its threads, which floods + /// the log (and can crash a large multi-threaded app or StikDebug itself). A + /// plain attach keeps them alive in the background without interfering. /// /// Returns `nil` on non-TXM devices, where JIT comes from `CS_DEBUGGED` /// alone and a plain attach is enough to hold the app. @@ -89,24 +91,7 @@ enum ScriptStore { return nil } - if let preferred = preferredScript(for: bundleID, fileManager: fileManager) { - return preferred - } - - return bundledScript(resourceName: "universal", fileName: "universal.js") - } - - /// Reads a bundled script straight from the app bundle (not the user's - /// scripts directory). Used for the keep-alive fallback so the current - /// shipped script — which supports `should_continue()` cancellation — is - /// always used, even for users whose documents copy predates it. - private static func bundledScript(resourceName: String, fileName: String) -> (data: Data, name: String)? { - guard let bundleURL = Bundle.main.url(forResource: resourceName, withExtension: "js"), - let data = try? Data(contentsOf: bundleURL) else { - return nil - } - - return (data, fileName) + return preferredScript(for: bundleID, fileManager: fileManager) } static func favoriteAppName(