Add iOS on-device debugging support#4999
Open
shai-almog wants to merge 8 commits into
Open
Conversation
Adds a JDWP-compatible debugger for ParparVM-built iOS apps so jdb /
IntelliJ / VS Code can attach to a real device or the iOS Simulator
and set breakpoints, walk the stack, and inspect locals + Strings.
Three pieces:
- ParparVM translator emits per-method side-tables (locals addresses,
variable names, line tables) and a cn1-symbols.txt sidecar when
-Dcn1.onDeviceDebug=true is set. Release builds are unaffected.
- A listener thread (Ports/iOSPort/nativeSources/cn1_debugger.{h,m})
is compiled into debug builds, dials out to a desktop proxy over
TCP, and services set/clear-bp, resume, step, get-stack/locals,
get-object-class, and get-string commands. The hot path in
__CN1_DEBUG_INFO is one predictable load+branch when nothing is
attached.
- A new Maven module (cn1-debug-proxy) bridges that custom protocol
to JDWP so any standard Java debugger speaks to it. Includes a
minimum-viable JDWP implementation covering everything jdb needs
for breakpoint, where, locals, and String inspection.
Maven goals: cn1:ios-on-device-debugging (launches the proxy) and
cn1:buildIosOnDeviceDebug (cloud build target).
Build-hint UX: codename1.arg.ios.onDeviceDebug=true plus
proxyHost/proxyPort. End-user docs live in
docs/developer-guide/On-Device-Debugging.asciidoc.
Contributor
|
Developer Guide build artifacts are available for download from this workflow run:
Developer Guide quality checks:
|
Contributor
Cloudflare Preview
|
Collaborator
Author
|
Compared 20 screenshots: 20 matched. |
Collaborator
Author
|
Compared 110 screenshots: 110 matched. Native Android coverage
✅ Native Android screenshot tests passed. Native Android coverage
Benchmark ResultsDetailed Performance Metrics
|
Collaborator
Author
|
Compared 110 screenshots: 110 matched. Benchmark Results
Build and Run Timing
Detailed Performance Metrics
|
Contributor
✅ ByteCodeTranslator Quality ReportTest & Coverage
Benchmark Results
Static Analysis
Generated automatically by the PR CI workflow. |
Collaborator
Author
|
Compared 110 screenshots: 110 matched. Benchmark Results
Build and Run Timing
Detailed Performance Metrics
|
- Force-off ios.onDeviceDebug on release builds (ios.buildType=release)
in both the translator JVM flag and the Info.plist injection, so a
stray hint in codenameone_settings.properties can't leak the debug
listener thread into an App Store binary.
- Document the new hints (ios.onDeviceDebug, .proxyHost, .proxyPort,
.waitForAttach) in the iOS build hints table in
Advanced-Topics-Under-The-Hood.asciidoc.
- Drop unused Parser.getClasses() that triggered MS_EXPOSE_REP.
- Rework the dev-guide chapter: remove the {cn1-release-version}
sentence from Prerequisites, drop the "macOS with Xcode required"
claim (the cloud build path works equally), drop the redundant
JDWP-debugger line, collapse the duplicated build instructions
into one step that points at the normal build flow, switch to
build-hint vocabulary, and strip the codename1.arg. prefixes from
the user-facing hint names.
- Fix Vale prose-linter regressions (contractions, first-person,
Latinisms).
# Conflicts: # maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java
Contributor
✅ Continuous Quality ReportTest & Coverage
Static Analysis
Generated automatically by the PR CI workflow. |
Quality-of-life improvements that emerged while running the proxy end-to-end
locally against the iOS simulator.
Device-side runtime (cn1_debugger.m + .h):
- cn1_debugger_start() no longer blocks the AppDelegate on
didFinishLaunchingWithOptions. The proxy connection runs on its own
thread regardless of CN1ProxyWaitForAttach, so UIKit can finish boot,
draw the launch transition, and -- when waitForAttach is on -- present
a translucent "Waiting for debugger..." overlay UIWindow. The previous
behaviour left the user staring at the splash with no signal that the
app was waiting on anything.
- New cn1_debugger_run_when_ready(block) API lets the AppDelegate defer
the VM start callback until the proxy reports the IDE has attached.
When waitForAttach is off (or on-device-debug is disabled at build
time) the block runs synchronously and behaves identically to the
pre-change boot flow.
GLAppDelegate:
- Calls cn1_debugger_run_when_ready around the VM callback so wait-mode
no longer races against splash dismissal, and captures the location
launch option into the block so it survives the deferral.
JDWP proxy (JdwpServer.java):
- acceptAndServe() now loops on accept() so the developer can detach and
reattach the IDE without restarting the proxy. Per-attach state is
reset via closeJdwpSession(); breakpoint registrations persist across
attaches.
- After handshake completes the proxy schedules an auto-resume that
releases the device-side waitForAttach gate 500 ms later. The delay
gives IntelliJ / VS Code time to register breakpoints before the app
races past them; without this the app sat on the waiting overlay
forever because most JDWP debuggers don't auto-send VM.Resume.
Misc:
- Add /artifacts/ to .gitignore (build wrapper drop-zone used by the
new ios-on-device-debugging mojo).
Three additions that turn the proxy from "stack-trace viewer" into a real interactive debugger. 1. Instance-field inspection Translator: ByteCodeClass.appendOnDeviceDebugFieldTable emits a per-class field-offset table (fieldId, offsetof, JVM type char, name) in each generated .m file, behind a CN1_ON_DEVICE_DEBUG guard. An __attribute__((constructor)) shim registers the table with cn1_debugger at process load. Parser.writeSymbolSidecar also emits `field <classId> <fieldId> <name> <descriptor> <accessFlags>` rows so the proxy can answer JDWP ClassType.Fields / FieldsWithGeneric without a device round-trip. Device: new CMD_GET_OBJECT_FIELDS handler walks the registered field table for the object's runtime classId and reads each field straight out of the struct using offsetof. Replies as EVT_OBJECT_FIELDS (count, then [type-char, value] tuples). Proxy: JdwpServer.handleObject case 2 (GetValues), handleStackFrame case 3 (ThisObject), and ClassType cases 4/14 (Fields, FieldsWithGeneric) now read real data instead of returning empty stubs. ThisObject piggybacks on the existing GetLocals path, reading slot 0 as an object reference for instance methods (the JVM always parks `this` there on entry); for statics slot 0 is zero, which is the correct JDWP reply. `dump this` in jdb against a running ParparVM-built iOS app now shows `tickCount: 4, pointerCount: 0` etc. — actual field values. 2. Native stdout / stderr forwarding cn1_debugger_start dup2()'s STDOUT_FILENO and STDERR_FILENO to pipes, then runs two streamCaptureThreads that chunk the pipes by newline. Each completed line is mirrored back to the original FD (so xcrun simctl log / Xcode console still works) and, if a proxy is connected, sent as EVT_STDOUT_LINE / EVT_STDERR_LINE. Proxy: handles the events and prints them prefixed with `[device]` to its own stdout (IntelliJ surfaces this in the proxy's debug console when it's launched as an IDE run config). System.out.println, Log.p, printf, fprintf(stderr,...) all flow through. NSLog on the iOS Simulator works too; on a real device NSLog may bypass stderr (documented as a limit). 3. CN1 core class breakpoints Confirmed working — sidecar already covers framework classes the same way it covers user code. The missing piece was just docs: On-Device-Debugging.asciidoc now describes how to attach the CodenameOne/src source directory in IntelliJ / jdb so the source pane resolves while stepping through framework code. Also tightened the "What works today" list and added a fresh "Known limitations" entry for static-field reads, plus a note that NSLog on a real device may bypass the stdout forwarder. Wire protocol additions: CMD_GET_OBJECT_FIELDS (0x0D), EVT_OBJECT_FIELDS (0x8A), EVT_STDOUT_LINE (0x8B), EVT_STDERR_LINE (0x8C). SymbolTable additions: FieldInfo, ClassInfo.instanceFields, fieldById, fieldCount. Verified end-to-end on iPhone 17 Pro / iOS 26.3 simulator under Xcode 26 against a minimal CN1 app: BP in framework's Display.edtLoopImpl fires, list shows the actual source, locals populate with their real names, and `dump this` walks the instance-field table.
Real bug found while testing stepping in IntelliJ: jdb's `next`
(step-over) returned `JDWP Error: 103` because the EventRequest.Set
modifier-loop walked past the end of the payload when JDI sent its
default ClassExclude modifiers (`java.*`, `javax.*`, `sun.*`,
`com.sun.*`, `jdk.internal.*`, etc. — JDI auto-attaches these to
every step request) and our switch's `default` branch set
`off = p.length`, then the next iteration read `p[p.length]` and
ArrayIndexOutOfBounds'd. The IDE's view: step request rejected → no
step event → app keeps running → looks indistinguishable from
"continue".
Fix: every modifier-case now bounds-checks before reading and bails
the loop via a `badModifier` flag if anything's off. Added the
missing modifier kinds JDI emits but we hadn't seen on the wire
yet (`FieldOnly`=9, `SourceNameMatch`=12), and changed the unknown-
kind branch to abort the loop with a `[jdwp]` log instead of trying
to guess the width.
Also fixed a related NPE: when the IDE detached mid-session, the
device-disconnect path tried to send VM_DEATH on an already-null
out stream and crashed the listener thread. writeEventCommand now
no-ops when out is null.
Added `[jdwp] STEP request` / `STEP_COMPLETE` / `VM.Resume` /
`Thread.Resume` log lines so future debugging of stepping is just
a matter of reading the proxy console.
Docs: documented the IntelliJ run-config trick for surfacing
device output ([device] lines) in the IDE console — launch the
proxy as an Application configuration alongside the Remote JVM
Debug attach and group them with a Compound run config. Without
this, the proxy's stdout (where device prints end up) only shows
up if the user runs the proxy from a terminal.
Verified end-to-end on iPhone 17 Pro / iOS 26.3 sim:
- jdb's `next` from a BP at heartbeat line 41 lands on line 42
(STEP_COMPLETE logged).
- jdb's `step` from a BP at line 42 enters the Log.p method
(STEP_COMPLETE for methodId=13294, line=242).
- Proxy survives an IDE detach and accepts the next reattach.
The IDE can now call any framework / user method on a paused VM and
have the result come back as a real object reference (or a value, or
a thrown Throwable). This is what makes `print
Display.getInstance().getCurrent().getTitle()` work in jdb, and
what makes IntelliJ's "Evaluate Expression" pop-up usable for chains
that involve method calls.
Translator (BytecodeMethod / ByteCodeClass / Parser):
- Emits one C "invoke thunk" per non-eliminated method, per class,
under CN1_ON_DEVICE_DEBUG. The thunk has a uniform signature
(tsd, this, args, *result), unpacks the args from a union into
the typed C parameters the translated method expects, dispatches
through virtual_<sym>() / <sym>() depending on virtuality, and
packs the return value back into the result union. The call is
wrapped in a catch-all try block so an uncaught Throwable
round-trips as result.type='X' instead of longjmp-ing past
suspendCurrent's cond_wait.
- Skips classes whose hand-written native impl has fallen out of
sync with the translator's calling convention: java.io.*,
java.net.*, java.nio.*, com.codename1.impl.*. The other system
packages (java.lang, java.util, ...) are fine because their
native impls are in nativeMethods.m and use the modern names.
- When on-device-debug is on, the unused-method optimiser keeps
every instance method of java.lang.Object alive — jdb's `print`
formats every object through Object.toString, so silently
dropping it earlier made every evaluation return "<void value>".
- Sidecar `method` rows now carry an isStatic flag; `class` rows
carry their superclass id so the proxy can answer
ClassType.Superclass and let JDI walk to inherited methods.
Device runtime (cn1_debugger.h/m):
- New cn1_invoke_arg union + cn1_invoke_result struct (JVM type-
char plus value slot), and a cn1_invoke_thunk_t function-pointer
type that the translator-emitted thunks all match.
- cn1_debugger_register_invoke_thunk(methodId, thunk) registry,
array-indexed by methodId for O(1) lookup.
- New CMD_INVOKE_METHOD handler. It queues the call on the
target thread's sus_state, signals s->cv, and blocks the
listener thread on a result-ready predicate. suspendCurrent's
cond_wait loop services the request on the suspended Java
thread (so the call runs in a valid tsd / GC context), then
goes back to waiting.
- New EVT_INVOKE_RESULT response carrying (type-char, 8-byte value).
Proxy (WireProtocol / DeviceConnection / SymbolTable / JdwpServer):
- WireProtocol: CMD_INVOKE_METHOD=0x0E, EVT_INVOKE_RESULT=0x8D.
- DeviceConnection: invokeMethod(threadId, methodId, thisObj,
argTypes[], argValues[]) and onInvokeResult(type, value)
callback wired through the listener.
- SymbolTable: MethodInfo.isStatic, ClassInfo.superId, and
extended `method`/`class` row parsing (still tolerates older
4-column rows).
- JdwpServer:
* ClassType.InvokeMethod (cmd 3) and
ObjectReference.InvokeMethod (cmd 6) parse the JDWP args,
forward as a CMD_INVOKE_METHOD, and pack the device's
EVT_INVOKE_RESULT into a JDWP returnValue + exception slot.
* ClassType.Superclass now returns the actual sidecar superId
so JDI walks the hierarchy properly instead of stopping at
every class.
* Methods / MethodsWithGeneric set the JDWP STATIC bit when
the sidecar marks the method static — without it jdb's
expression parser refuses to resolve `Class.method()`.
Verified end-to-end on iPhone 17 Pro / iOS 26.3 simulator. Single-
invoke and chained:
print com.codename1.ui.Display.getInstance() -> Display ref
print Display.getInstance().getCurrent() -> Form ref
print Display.getInstance().getCurrent().getTitle() -> String "hello, world"
Object.toString round-trips through nativeMethods.m, so jdb's
default object-display formatting also works.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Adds a JDWP-compatible debugger for ParparVM-built iOS apps so
jdb,IntelliJ IDEA, VS Code, Eclipse, NetBeans — anything that speaks
JDWP — can attach to a real device or the iOS Simulator and set
breakpoints, walk the stack, and inspect locals + Strings on the
running app.
End-user documentation is in
docs/developer-guide/On-Device-Debugging.asciidoc.Architecture
Three pieces, each independent:
Translator instrumentation. When
-Dcn1.onDeviceDebug=trueisset, the ParparVM translator emits per-method side-tables (locals
address arrays, variable names, line tables) and a
cn1-symbols.txtsidecar that the desktop proxy uses for nameresolution. Release builds are completely unaffected — gated by a
CN1_ON_DEVICE_DEBUGpreprocessor define.Device runtime.
Ports/iOSPort/nativeSources/cn1_debugger.{h,m}is compiled into debug builds only. It dials out to a desktop
proxy over TCP, then services the wire protocol from a listener
thread: set/clear-bp, resume, step (into/over/out), get-stack,
get-locals, get-object-class, get-string. Suspend/resume yields
the GC bit so a paused thread doesn't block collection. The hot
path in
__CN1_DEBUG_INFOis one predictable load+branch whennothing is attached (
__builtin_expect(cn1DebuggerActive, 0)).Desktop proxy. New Maven module
maven/cn1-debug-proxy/contains a minimum-viable JDWP server (
JdwpServer) thattranslates our custom protocol to/from JDWP. Covers the commands
jdbactually issues — handshake, VM.Version/IDSizes/Capabilities,AllClasses[WithGeneric], Method.LineTable/VariableTable[WithGeneric],
EventRequest.Set/Clear, Event.Composite (VM_START, BREAKPOINT,
SINGLE_STEP, VM_DEATH), ThreadReference.Name/Frames/FrameCount/Status,
StackFrame.GetValues, ObjectReference.ReferenceType, StringReference.Value.
Build hints (UX)
In
common/codenameone_settings.properties:For a physical device, set
proxyHostto the laptop's LAN IP.New Maven goals
mvn cn1:ios-on-device-debugging— autodetects the symbol sidecar,launches the proxy, and prints attach instructions (
jdb -attach localhost:8000).mvn cn1:buildIosOnDeviceDebug— cloud-build target that forces theon-device-debug flag on. Routes through the debug iOS pipeline (a
new
ios-on-device-debugant target maps to debug cert / ad-hocprovisioning).
IPhoneBuilderreadsios.onDeviceDebugand (a) threads-Dcn1.onDeviceDebug=trueinto the translator JVM and (b) injectsCN1ProxyHost/CN1ProxyPort/CN1ProxyWaitForAttachand an ATSexemption into Info.plist when the flag is set.
What works today
java.lang.Stringvalue inspection — strings show as"hello".variables view.
Known limitations (documented in the dev guide)
"evaluate expression" can read existing values but not call methods).
-ghaving been used at javac time— Codename One archetypes set this by default; classes built
without it show up as
v1,v2, ...Performance
Release builds: zero overhead (no listener, no metadata, no per-line
callback — guarded by
CN1_ON_DEVICE_DEBUGpreprocessor define).Debug builds, no debugger attached: one predictable load+branch per
source line (the existing
__CN1_DEBUG_INFOfor stack-trace linerecording is already there; we add a flag check).
Debug builds, debugger attached: ~2-3× slowdown in tight numeric
loops, consistent with
-goverhead on other native VMs.Verification
End-to-end smoke test against a real iOS Simulator app:
mvn cn1:buildIosXcodeProject -Dcodename1.arg.ios.onDeviceDebug=truegenerates an Xcode project;
xcodebuildsucceeds foriphonesimulator/arm64.jdb -attachsucceeds.
stop at com.example.DebugApp:22, the breakpoint fires andjdbshows the full 9-frame stack walked up throughDisplay.executeSerialCall/mainEDTLoop/CodenameOneThread.run.localsshowsname = "world",greeting = "hello, world", plusthe
FormandButtoninstances with class + identity.print namereturnsname = "world"directly viaStringReference.Value.Companion BuildDaemon PR mirrors the cloud-build binding:
https://github.com/codenameone/BuildDaemon (separate PR to follow).
Test plan
mvn -Plocal-dev-javase installstill succeeds with thenew
cn1-debug-proxymodule registered.current behaviour when
ios.onDeviceDebugis unset.jdbsmoke test on a fresh archetypeproject.
buildIosOnDeviceDebugbuild round-trips andthe resulting
.ipaconnects to the proxy from a tethereddevice on the same Wi-Fi.
build.