Run the JIT script in hold-alive mode so attached apps get working JIT

The experimental "Hold App Alive in Background" mode attached the debugger
(setting CS_DEBUGGED) but never ran the app's assigned JIT script. On
TXM / iOS 26+ devices JIT is not enabled merely by attaching: the app
requests executable memory at runtime via `brk #0xf00d` syscalls that the
debugger must service, which is exactly what the JIT script does in its
breakpoint loop. Without the script those syscalls went unhandled, so the
app trapped and hung (or JIT silently failed) on its first JIT call --
"the JIT script doesn't run when attaching to an app."

Thread the same JIT-script callback the normal debug path uses through
the hold flow:

- HomeView.startKeepAlive resolves the app's preferred script (gated on
  hasTXM, exactly like startJITInBackground) and passes it down.
- BackgroundAliveManager.start and JITEnableContext.keepAppAlive forward
  it to holdDebugSession.
- holdDebugSession, when a script is present, runs it (the script does its
  own vAttach + JIT enable + continue and services the app's JIT syscalls
  for as long as the app uses JIT, keeping the app alive under the
  debugger). When absent it keeps the previous attach-and-hold behavior
  for older devices where CS_DEBUGGED alone enables JIT.

The normal (non-hold) JIT path is unchanged. Known limitation: for an app
whose script runs continuously, the hold ends when the app is closed
(matching the normal flow, which likewise cannot interrupt a running
script); the Stop button fully applies to the no-script hold path.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013DNqCsHShA4yNqgZ6ZZQy3
This commit is contained in:
Claude 2026-07-15 06:01:15 +00:00
parent d2f09bea32
commit 42951c6e10
No known key found for this signature in database
3 changed files with 71 additions and 16 deletions

View file

@ -642,8 +642,9 @@ final class JITEnableContext {
}
}
/// Launches an app, attaches the debugger, and *holds* the session open so the
/// app keeps running in the background.
/// Launches an app, attaches the debugger, runs the app's assigned JIT
/// script (if any), and *holds* the session open so the app keeps running
/// in the background.
///
/// A debugger-owned process is not suspended by iOS when it moves to the
/// background (the same reason an app run from Xcode keeps executing while
@ -652,10 +653,14 @@ final class JITEnableContext {
/// background jetsam kill. The hold runs until `cancellation` is set, then we
/// interrupt and detach so the app continues running on its own.
///
/// - Parameter script: The same JIT-script callback the normal debug path
/// uses. When present it runs once (doing its own attach + JIT enable +
/// continue) before the hold begins, so scripts work in hold mode too;
/// when `nil` the app is simply attached and continued.
/// - Note: This keeps StikDebug's connection to *this* app alive; it cannot
/// revive an app iOS has already killed. Requires StikDebug itself to stay
/// running (see `DebugKeepAliveLease`).
func keepAppAlive(withBundleID bundleID: String, cancellation: HoldToken, logger: LogFunc?) -> Bool {
func keepAppAlive(withBundleID bundleID: String, script: DebugAppCallback?, cancellation: HoldToken, logger: LogFunc?) -> Bool {
do {
try withConnectedDebugSession { remoteServer, debugProxy in
let pid = try withProcessControl(remoteServer: remoteServer) { processControl in
@ -677,7 +682,14 @@ final class JITEnableContext {
return Int32(truncatingIfNeeded: pid)
}
try holdDebugSession(pid: pid, debugProxy: debugProxy, logger: logger, cancellation: cancellation)
try holdDebugSession(
pid: pid,
debugProxy: debugProxy,
remoteServer: remoteServer,
script: script,
logger: logger,
cancellation: cancellation
)
}
emitLog("Keep-alive session ended for \(bundleID)", logger: logger)
@ -688,7 +700,14 @@ final class JITEnableContext {
}
}
private func holdDebugSession(pid: Int32, debugProxy: OpaquePointer, logger: LogFunc?, cancellation: HoldToken) throws {
private func holdDebugSession(
pid: Int32,
debugProxy: OpaquePointer,
remoteServer: OpaquePointer,
script: DebugAppCallback?,
logger: LogFunc?,
cancellation: HoldToken
) throws {
debug_proxy_send_ack(debugProxy)
debug_proxy_send_ack(debugProxy)
@ -701,23 +720,47 @@ final class JITEnableContext {
debug_proxy_set_ack_mode(debugProxy, 0)
let attachCommand = "vAttach;\(String(UInt32(bitPattern: pid), radix: 16))"
let attachResponse = try sendDebugCommand(attachCommand, debugProxy: debugProxy) ?? "<nil>"
emitLog("Keep-alive attach response: \(attachResponse)", logger: logger)
// Keep the RSD/lockdown tunnel trusted for the whole hold.
let heartbeat = try? connectHeartbeatKeepAlive(logger: logger)
try? heartbeat?.start()
defer { heartbeat?.stop() }
// Continue the (suspended-launched) process so it runs. Sent raw and not
// awaited: a debugger-owned, running process is not stopped by iOS when
// backgrounded, so there are no stop replies to drain — we just hold the
// connection open. Polling a flag (rather than blocking on a socket read)
// keeps this single-threaded and cleanly cancellable.
if let script {
// TXM / iOS 26+ JIT: the app requests executable memory at runtime via
// `brk #0xf00d` syscalls that the debugger must service. That is exactly
// what the JIT script does — it attaches (`vAttach`, enabling JIT),
// then loops handling those breakpoints for as long as the app uses
// JIT, which also keeps the app alive under the debugger the whole
// time. Merely attaching without running the script (the old hold
// behavior) left those syscalls unhandled, so the app trapped and hung
// on its first JIT call — the bug this fixes.
//
// The script issues its own attach/continue via `send_command`, so we
// must NOT send `vAttach` here (that would double-attach). It runs
// until the app detaches or exits; the semaphore blocks until then.
// StikDebug itself is held alive across this by the caller's
// `DebugKeepAliveLease`.
emitLog("Keep-alive: running JIT script (services JIT and holds the app)", logger: logger)
let semaphore = DispatchSemaphore(value: 0)
script(pid, debugProxy, remoteServer, semaphore)
semaphore.wait()
emitLog("Keep-alive: JIT script ended (app detached or exited)", logger: logger)
return
}
// No script (older devices where JIT is enabled simply by CS_DEBUGGED):
// attach to set CS_DEBUGGED, then continue so the app runs. Sent raw and
// not awaited: a debugger-owned, running process is not stopped by iOS
// when backgrounded, so there are no stop replies to drain — we just hold
// the connection open until the user stops it.
let attachCommand = "vAttach;\(String(UInt32(bitPattern: pid), radix: 16))"
let attachResponse = try sendDebugCommand(attachCommand, debugProxy: debugProxy) ?? "<nil>"
emitLog("Keep-alive attach response: \(attachResponse)", logger: logger)
sendContinue(debugProxy)
emitLog("Keep-alive: app is running and held under the debugger", logger: logger)
// Polling a flag (rather than blocking on a socket read) keeps this
// single-threaded and cleanly cancellable.
while !cancellation.isCancelled {
Thread.sleep(forTimeInterval: 0.5)
}

View file

@ -48,7 +48,10 @@ final class BackgroundAliveManager: ObservableObject {
return activeBundleID != nil
}
func start(bundleID: String, displayName: String?) {
/// - Parameter script: Optional JIT-script callback to run once (after
/// attach, before the hold begins) so the app's assigned script executes
/// in hold mode just like it does for a normal JIT run.
func start(bundleID: String, displayName: String?, script: DebugAppCallback? = nil) {
lock.lock()
guard activeBundleID == nil else {
lock.unlock()
@ -76,6 +79,7 @@ final class BackgroundAliveManager: ObservableObject {
let succeeded = JITEnableContext.shared.keepAppAlive(
withBundleID: bundleID,
script: script,
cancellation: token,
logger: logger
)

View file

@ -291,9 +291,17 @@ struct HomeView: View {
)
return
}
// Resolve the app's assigned JIT script the same way a normal JIT run
// does, so the script also runs in hold mode instead of only attaching.
var script: DebugAppCallback? = nil
if ProcessInfo.processInfo.hasTXM,
let preferred = ScriptStore.preferredScript(for: bundleID) {
script = getJsCallback(preferred.data, name: preferred.name)
}
let startingMessage = String(format: "Keeping %@ alive in the background".localized, name)
AccessibilityAnnouncer.announce(startingMessage)
BackgroundAliveManager.shared.start(bundleID: bundleID, displayName: displayName)
BackgroundAliveManager.shared.start(bundleID: bundleID, displayName: displayName, script: script)
}
private func debugFeedbackView(_ feedback: DebugFeedback) -> some View {