From fbedbc552540c39a93a98664dd98f16d7b3f40fe Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Thu, 21 May 2026 22:38:05 +0300 Subject: [PATCH 01/13] Add iOS on-device debugging support 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. --- .../nativeSources/CodenameOne_GLAppDelegate.m | 10 +- Ports/iOSPort/nativeSources/cn1_debugger.h | 33 + Ports/iOSPort/nativeSources/cn1_debugger.m | 751 +++++++++++ .../On-Device-Debugging.asciidoc | 224 ++++ docs/developer-guide/developer-guide.asciidoc | 2 + maven/cn1-debug-proxy/pom.xml | 54 + .../debug/proxy/DeviceConnection.java | 267 ++++ .../com/codename1/debug/proxy/JdwpServer.java | 1096 +++++++++++++++++ .../debug/proxy/LoggingListener.java | 83 ++ .../com/codename1/debug/proxy/ProxyMain.java | 96 ++ .../codename1/debug/proxy/SymbolTable.java | 191 +++ .../codename1/debug/proxy/WireProtocol.java | 52 + .../com/codename1/builders/IPhoneBuilder.java | 28 +- .../maven/IosOnDeviceDebuggingMojo.java | 160 +++ .../BuildIosOnDeviceDebugMojo.java | 74 ++ .../com/codename1/maven/buildxml-template.xml | 32 + maven/pom.xml | 1 + vm/ByteCodeTranslator/src/cn1_globals.h | 63 +- vm/ByteCodeTranslator/src/cn1_globals.m | 16 + .../tools/translator/ByteCodeClass.java | 6 +- .../tools/translator/ByteCodeTranslator.java | 6 + .../tools/translator/BytecodeMethod.java | 144 ++- .../codename1/tools/translator/Parser.java | 81 +- .../translator/bytecodes/LineNumber.java | 4 + .../translator/bytecodes/LocalVariable.java | 15 + vm/ByteCodeTranslator/src/nativeMethods.m | 13 +- 26 files changed, 3494 insertions(+), 8 deletions(-) create mode 100644 Ports/iOSPort/nativeSources/cn1_debugger.h create mode 100644 Ports/iOSPort/nativeSources/cn1_debugger.m create mode 100644 docs/developer-guide/On-Device-Debugging.asciidoc create mode 100644 maven/cn1-debug-proxy/pom.xml create mode 100644 maven/cn1-debug-proxy/src/main/java/com/codename1/debug/proxy/DeviceConnection.java create mode 100644 maven/cn1-debug-proxy/src/main/java/com/codename1/debug/proxy/JdwpServer.java create mode 100644 maven/cn1-debug-proxy/src/main/java/com/codename1/debug/proxy/LoggingListener.java create mode 100644 maven/cn1-debug-proxy/src/main/java/com/codename1/debug/proxy/ProxyMain.java create mode 100644 maven/cn1-debug-proxy/src/main/java/com/codename1/debug/proxy/SymbolTable.java create mode 100644 maven/cn1-debug-proxy/src/main/java/com/codename1/debug/proxy/WireProtocol.java create mode 100644 maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/IosOnDeviceDebuggingMojo.java create mode 100644 maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/buildWrappers/BuildIosOnDeviceDebugMojo.java diff --git a/Ports/iOSPort/nativeSources/CodenameOne_GLAppDelegate.m b/Ports/iOSPort/nativeSources/CodenameOne_GLAppDelegate.m index e3558d8115..da18a1bc09 100644 --- a/Ports/iOSPort/nativeSources/CodenameOne_GLAppDelegate.m +++ b/Ports/iOSPort/nativeSources/CodenameOne_GLAppDelegate.m @@ -322,9 +322,17 @@ - (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:( // Override point for customization after application launch. - // Install signal handlers so that rather than the app crashing upon a BAD_ACCESS, the + // Install signal handlers so that rather than the app crashing upon a BAD_ACCESS, the // app will throw an NPE. installSignalHandlers(); +#ifdef CN1_ON_DEVICE_DEBUG + // Bring up the on-device-debug listener BEFORE the VM callback so the + // proxy can already be attached when user code starts running. The + // listener spawns its own thread and may block boot if Info.plist + // sets CN1ProxyWaitForAttach=YES. + extern void cn1_debugger_start(void); + cn1_debugger_start(); +#endif [self cn1EnsureViewController]; #ifndef CN1_USE_UI_SCENE [self cn1InstallRootViewControllerIntoWindow:self.window]; diff --git a/Ports/iOSPort/nativeSources/cn1_debugger.h b/Ports/iOSPort/nativeSources/cn1_debugger.h new file mode 100644 index 0000000000..9de20ad84d --- /dev/null +++ b/Ports/iOSPort/nativeSources/cn1_debugger.h @@ -0,0 +1,33 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + */ + +#ifndef CN1_DEBUGGER_H +#define CN1_DEBUGGER_H + +#include "cn1_globals.h" + +#ifdef CN1_ON_DEVICE_DEBUG + +/** + * Boots the on-device-debug listener thread. Reads the desktop proxy host + * and port from Info.plist keys CN1ProxyHost and CN1ProxyPort, opens an + * outbound TCP connection, sends a HELLO event, and services commands + * (set/clear breakpoint, resume, step, get stack, get locals) in a loop. + * + * Called from CodenameOne_GLAppDelegate.m's application:didFinishLaunching + * after signal handlers are in place but before the Java VM callback. If + * Info.plist has CN1ProxyWaitForAttach=YES the function blocks until the + * proxy connects and sends RESUME; otherwise it returns immediately and the + * listener thread retries the connection in the background. + */ +extern void cn1_debugger_start(void); + +#endif // CN1_ON_DEVICE_DEBUG +#endif // CN1_DEBUGGER_H diff --git a/Ports/iOSPort/nativeSources/cn1_debugger.m b/Ports/iOSPort/nativeSources/cn1_debugger.m new file mode 100644 index 0000000000..052f210b58 --- /dev/null +++ b/Ports/iOSPort/nativeSources/cn1_debugger.m @@ -0,0 +1,751 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * On-device-debug runtime. Single-file implementation: wire-protocol + * encode/decode, breakpoint hash, per-thread suspend/resume, command + * handlers. The translator emits per-frame metadata that this file reads + * (callStackFrameInfo, callStackLocalsAddresses) — see cn1_globals.h and + * BytecodeMethod.appendLocalsAddressTable for the format. + * + * Wire protocol (binary, network byte order, length-prefixed): + * [u32 payload-length] [u8 command/event-code] [payload-bytes...] + * + * Codes 0x01-0x7F are commands (proxy -> device); + * codes 0x80-0xFF are events (device -> proxy). See the #defines below. + */ + +#import "cn1_debugger.h" + +#ifdef CN1_ON_DEVICE_DEBUG + +#import +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#define CN1_DBG_PROTOCOL_VERSION 1 + +// Commands (proxy -> device) +#define CMD_SET_BREAKPOINT 0x02 +#define CMD_CLEAR_BREAKPOINT 0x03 +#define CMD_RESUME 0x04 +#define CMD_SUSPEND 0x05 +#define CMD_GET_THREADS 0x06 +#define CMD_GET_STACK 0x07 +#define CMD_GET_LOCALS 0x08 +#define CMD_STEP 0x09 +#define CMD_DISPOSE 0x0A +#define CMD_GET_STRING 0x0B +#define CMD_GET_OBJECT_CLASS 0x0C + +// Events (device -> proxy) +#define EVT_HELLO 0x80 +#define EVT_THREAD_LIST 0x81 +#define EVT_BP_HIT 0x82 +#define EVT_STEP_COMPLETE 0x83 +#define EVT_STACK 0x84 +#define EVT_LOCALS 0x85 +#define EVT_VM_DEATH 0x86 +#define EVT_STRING_VALUE 0x87 +#define EVT_REPLY_STATUS 0x88 +#define EVT_OBJECT_CLASS 0x89 + +// java.lang.String's clazz struct is emitted by the translator; we reference +// it by symbol so cn1_debugger.m doesn't depend on the generated +// cn1_class_method_index.h. Used in handleGetLocals to tag String references +// with JDWP type 's' so jdb can call StringReference.Value directly instead +// of falling through to a (currently unsupported) toString() InvokeMethod. +extern struct clazz class__java_lang_String; + +// Step kinds +#define STEP_INTO 0 +#define STEP_OVER 1 +#define STEP_OUT 2 + +// Override the weak default in cn1_globals.m. Flipped to 1 once a proxy is +// connected and out of the HELLO handshake. +volatile int cn1DebuggerActive = 0; + +static int g_proxyFd = -1; +static pthread_mutex_t g_writeMutex = PTHREAD_MUTEX_INITIALIZER; +static int g_waitForAttach = 0; +static pthread_mutex_t g_attachMutex = PTHREAD_MUTEX_INITIALIZER; +static pthread_cond_t g_attachCond = PTHREAD_COND_INITIALIZER; + +/* --------------------------------------------------------------------- */ +/* Breakpoint hash. Open-addressed, lock-free reads via atomic 64-bit */ +/* slots. Key = (methodId << 32) | line; zero is the empty sentinel, */ +/* which means line 0 of methodId 0 is reserved (no real source uses it).*/ +/* --------------------------------------------------------------------- */ + +#define BP_TABLE_SIZE 1024 +#define BP_TABLE_MASK (BP_TABLE_SIZE - 1) +static _Atomic uint64_t g_bps[BP_TABLE_SIZE]; + +static inline uint32_t bp_hash(uint64_t key) { + // splitmix64-ish reduction + key ^= key >> 33; + key *= 0xff51afd7ed558ccdULL; + key ^= key >> 33; + return (uint32_t)key & BP_TABLE_MASK; +} + +static int bp_contains(int methodId, int line) { + uint64_t key = ((uint64_t)(uint32_t)methodId << 32) | (uint32_t)line; + uint32_t h = bp_hash(key); + for (int i = 0; i < BP_TABLE_SIZE; i++) { + uint64_t v = atomic_load_explicit(&g_bps[(h + i) & BP_TABLE_MASK], memory_order_acquire); + if (v == 0) return 0; + if (v == key) return 1; + } + return 0; +} + +static void bp_add(int methodId, int line) { + uint64_t key = ((uint64_t)(uint32_t)methodId << 32) | (uint32_t)line; + uint32_t h = bp_hash(key); + for (int i = 0; i < BP_TABLE_SIZE; i++) { + uint64_t expected = 0; + if (atomic_compare_exchange_strong_explicit( + &g_bps[(h + i) & BP_TABLE_MASK], &expected, key, + memory_order_acq_rel, memory_order_relaxed)) { + return; + } + if (expected == key) return; // already present + } + NSLog(@"cn1_debugger: breakpoint table full"); +} + +static void bp_clear(int methodId, int line) { + // Simple zero-out. Linear probing tolerates holes only because we stop + // on zero, which would terminate searches early — so on delete we shift + // subsequent matching slots up. With a small table and few breakpoints + // a full re-probe is fine. + uint64_t key = ((uint64_t)(uint32_t)methodId << 32) | (uint32_t)line; + uint32_t h = bp_hash(key); + int found = -1; + for (int i = 0; i < BP_TABLE_SIZE; i++) { + uint64_t v = atomic_load_explicit(&g_bps[(h + i) & BP_TABLE_MASK], memory_order_acquire); + if (v == 0) break; + if (v == key) { + found = (h + i) & BP_TABLE_MASK; + break; + } + } + if (found < 0) return; + atomic_store_explicit(&g_bps[found], 0, memory_order_release); + // Re-insert any following non-empty slots whose ideal slot is <= found + // so future lookups don't terminate prematurely. + int j = (found + 1) & BP_TABLE_MASK; + while (1) { + uint64_t v = atomic_load_explicit(&g_bps[j], memory_order_acquire); + if (v == 0) break; + atomic_store_explicit(&g_bps[j], 0, memory_order_release); + bp_add((int)(v >> 32), (int)(v & 0xFFFFFFFFULL)); + j = (j + 1) & BP_TABLE_MASK; + } +} + +/* --------------------------------------------------------------------- */ +/* Per-thread suspend state. Keyed by ThreadLocalData->threadId hashed */ +/* down into a fixed-size table — ParparVM caps at 1024 simultaneous */ +/* threads so we mirror that. Each slot owns a mutex + condvar that the */ +/* hitting Java thread blocks on; the listener thread signals to resume. */ +/* --------------------------------------------------------------------- */ + +struct sus_state { + pthread_mutex_t mu; + pthread_cond_t cv; + int suspended; + int stepKind; // -1 = none, otherwise STEP_INTO/OVER/OUT + int stepFromDepth; // callStackOffset captured at suspend + struct ThreadLocalData* tsd; // current frame owner while suspended +}; + +#define SUS_TABLE_SIZE 1024 +static struct sus_state g_sus[SUS_TABLE_SIZE]; +static _Atomic int g_susInit = 0; + +static void ensureSusInit(void) { + int expected = 0; + if (atomic_compare_exchange_strong(&g_susInit, &expected, 1)) { + for (int i = 0; i < SUS_TABLE_SIZE; i++) { + pthread_mutex_init(&g_sus[i].mu, NULL); + pthread_cond_init(&g_sus[i].cv, NULL); + g_sus[i].suspended = 0; + g_sus[i].stepKind = -1; + g_sus[i].tsd = NULL; + } + } +} + +static struct sus_state* susForThread(int64_t threadId) { + ensureSusInit(); + return &g_sus[((uint64_t)threadId) & (SUS_TABLE_SIZE - 1)]; +} + +/* --------------------------------------------------------------------- */ +/* Wire I/O. Send/recv all bytes or fail. The write side is serialised */ +/* by g_writeMutex so an event from a Java thread can't interleave with */ +/* a reply from the command loop. */ +/* --------------------------------------------------------------------- */ + +static int sendAll(int fd, const void* buf, size_t n) { + const char* p = (const char*)buf; + while (n > 0) { + ssize_t w = send(fd, p, n, 0); + if (w <= 0) { + if (w < 0 && errno == EINTR) continue; + return -1; + } + p += w; n -= (size_t)w; + } + return 0; +} + +static int readAll(int fd, void* buf, size_t n) { + char* p = (char*)buf; + while (n > 0) { + ssize_t r = recv(fd, p, n, 0); + if (r <= 0) { + if (r < 0 && errno == EINTR) continue; + return -1; + } + p += r; n -= (size_t)r; + } + return 0; +} + +static void sendEvent(uint8_t cmd, const void* payload, uint32_t len) { + if (g_proxyFd < 0) return; + pthread_mutex_lock(&g_writeMutex); + uint32_t lenBE = htonl(len); + int ok = (sendAll(g_proxyFd, &lenBE, 4) == 0) + && (sendAll(g_proxyFd, &cmd, 1) == 0) + && (len == 0 || sendAll(g_proxyFd, payload, len) == 0); + pthread_mutex_unlock(&g_writeMutex); + if (!ok) { + NSLog(@"cn1_debugger: send failed, closing"); + cn1DebuggerActive = 0; + // Don't close fd here; let the read loop notice and tear down. + } +} + +/* --------------------------------------------------------------------- */ +/* Suspend / resume. suspendCurrent releases the GC bit so a long pause */ +/* doesn't block collection. resumeThread signals the condvar. */ +/* --------------------------------------------------------------------- */ + +static void suspendCurrent(struct ThreadLocalData* tsd) { + int64_t threadId = (int64_t)tsd->threadId; + struct sus_state* s = susForThread(threadId); + pthread_mutex_lock(&s->mu); + s->suspended = 1; + s->stepFromDepth = tsd->callStackOffset; + s->tsd = tsd; + // Mark thread inactive so the concurrent GC can mark/sweep freely. + tsd->threadActive = JAVA_FALSE; + while (s->suspended) { + pthread_cond_wait(&s->cv, &s->mu); + } + // GC may have parked us; wait for it to finish before resuming. + while (tsd->threadBlockedByGC) { + pthread_mutex_unlock(&s->mu); + usleep(1000); + pthread_mutex_lock(&s->mu); + } + tsd->threadActive = JAVA_TRUE; + s->tsd = NULL; + pthread_mutex_unlock(&s->mu); +} + +/* + * resumeThreadById signals a suspended thread to continue but does NOT + * touch its stepKind — so a CMD_STEP that ran earlier (which sets the + * step kind) survives a subsequent CMD_RESUME from jdb. Caller passes + * preserveStep=1 to leave stepKind alone, or 0 to also reset to -1. + */ +static void resumeThreadById(int64_t threadId, int preserveStep) { + struct sus_state* s = susForThread(threadId); + pthread_mutex_lock(&s->mu); + if (!preserveStep) { + s->stepKind = -1; + } + s->suspended = 0; + pthread_cond_signal(&s->cv); + pthread_mutex_unlock(&s->mu); +} + +static void resumeAll(int preserveStep) { + for (int i = 0; i < SUS_TABLE_SIZE; i++) { + pthread_mutex_lock(&g_sus[i].mu); + if (g_sus[i].suspended) { + if (!preserveStep) { + g_sus[i].stepKind = -1; + } + g_sus[i].suspended = 0; + pthread_cond_signal(&g_sus[i].cv); + } + pthread_mutex_unlock(&g_sus[i].mu); + } +} + +/* Sets the step state on a thread and wakes it if currently suspended. */ +static void setStepAndResume(int64_t threadId, int stepKind) { + struct sus_state* s = susForThread(threadId); + pthread_mutex_lock(&s->mu); + s->stepKind = stepKind; + if (s->suspended) { + s->suspended = 0; + pthread_cond_signal(&s->cv); + } + pthread_mutex_unlock(&s->mu); +} + +/* --------------------------------------------------------------------- */ +/* Hot-path check called from __CN1_DEBUG_INFO. Strong definition that */ +/* shadows the weak stub in cn1_globals.m. Reached only when */ +/* cn1DebuggerActive is non-zero. */ +/* --------------------------------------------------------------------- */ + +/* + * Byte-explicit big-endian writers. Avoids the htonl-and-shift trap on + * little-endian hosts where the order of bytes inside a uint64_t depends + * on host endianness and the shift composition doesn't actually yield + * network byte order in memory. + */ +static inline void writeBE32(uint8_t* dst, uint32_t v) { + dst[0] = (uint8_t)(v >> 24); dst[1] = (uint8_t)(v >> 16); + dst[2] = (uint8_t)(v >> 8); dst[3] = (uint8_t)v; +} +static inline void writeBE64(uint8_t* dst, uint64_t v) { + dst[0] = (uint8_t)(v >> 56); dst[1] = (uint8_t)(v >> 48); + dst[2] = (uint8_t)(v >> 40); dst[3] = (uint8_t)(v >> 32); + dst[4] = (uint8_t)(v >> 24); dst[5] = (uint8_t)(v >> 16); + dst[6] = (uint8_t)(v >> 8); dst[7] = (uint8_t)v; +} + +static void emitLocationEvent(uint8_t code, int64_t threadId, int methodId, int line) { + uint8_t buf[16]; + writeBE64(buf, (uint64_t)threadId); + writeBE32(buf + 8, (uint32_t)methodId); + writeBE32(buf + 12, (uint32_t)line); + sendEvent(code, buf, 16); +} + +void cn1_debugger_check(struct ThreadLocalData* tsd, int line) { + if (tsd->callStackOffset <= 0) return; + const struct cn1_frame_info* fi = tsd->callStackFrameInfo[tsd->callStackOffset - 1]; + if (fi == NULL) return; + int methodId = fi->methodId; + int64_t threadId = (int64_t)tsd->threadId; + + // Stepping has priority over breakpoints so a step that lands on a + // breakpoint reports once (as STEP_COMPLETE). + struct sus_state* s = susForThread(threadId); + int sk = s->stepKind; + if (sk >= 0) { + int depth = tsd->callStackOffset; + int shouldStop = 0; + switch (sk) { + case STEP_INTO: shouldStop = 1; break; + case STEP_OVER: shouldStop = (depth <= s->stepFromDepth); break; + case STEP_OUT: shouldStop = (depth < s->stepFromDepth); break; + } + if (shouldStop) { + // Clear step state under the per-thread mutex so a concurrent + // CMD_STEP can't race the clear. + pthread_mutex_lock(&s->mu); + s->stepKind = -1; + pthread_mutex_unlock(&s->mu); + emitLocationEvent(EVT_STEP_COMPLETE, threadId, methodId, line); + suspendCurrent(tsd); + return; + } + } + if (bp_contains(methodId, line)) { + emitLocationEvent(EVT_BP_HIT, threadId, methodId, line); + suspendCurrent(tsd); + } +} + +/* --------------------------------------------------------------------- */ +/* Command-loop handlers. Stack / locals are read from the suspended */ +/* thread's tsd which was recorded into the sus_state when it parked. */ +/* --------------------------------------------------------------------- */ + +static void handleGetStack(int64_t threadId) { + struct sus_state* s = susForThread(threadId); + pthread_mutex_lock(&s->mu); + struct ThreadLocalData* tsd = s->tsd; + if (tsd == NULL || tsd->callStackOffset <= 0) { + pthread_mutex_unlock(&s->mu); + uint8_t buf[12]; + writeBE64(buf, (uint64_t)threadId); + writeBE32(buf + 8, 0); + sendEvent(EVT_STACK, buf, 12); + return; + } + int depth = tsd->callStackOffset; + // Cap depth to keep payload sane. + if (depth > 256) depth = 256; + size_t sz = 8 + 4 + (size_t)depth * 8; + uint8_t* buf = (uint8_t*)malloc(sz); + writeBE64(buf, (uint64_t)threadId); + writeBE32(buf + 8, (uint32_t)depth); + // Frames emitted innermost-first. + for (int i = 0; i < depth; i++) { + int frameIdx = (tsd->callStackOffset - 1 - i); + int methodId = 0; + const struct cn1_frame_info* fi = tsd->callStackFrameInfo[frameIdx]; + if (fi) methodId = fi->methodId; + int line = tsd->callStackLine[frameIdx]; + writeBE32(buf + 12 + i * 8, (uint32_t)methodId); + writeBE32(buf + 12 + i * 8 + 4, (uint32_t)line); + } + pthread_mutex_unlock(&s->mu); + sendEvent(EVT_STACK, buf, (uint32_t)sz); + free(buf); +} + +static void handleGetLocals(int64_t threadId, int frameOffsetFromTop) { + struct sus_state* s = susForThread(threadId); + pthread_mutex_lock(&s->mu); + struct ThreadLocalData* tsd = s->tsd; + if (tsd == NULL || tsd->callStackOffset <= 0 + || frameOffsetFromTop < 0 + || frameOffsetFromTop >= tsd->callStackOffset) { + pthread_mutex_unlock(&s->mu); + uint32_t zero = 0; + sendEvent(EVT_LOCALS, &zero, 4); + return; + } + int frameIdx = tsd->callStackOffset - 1 - frameOffsetFromTop; + const struct cn1_frame_info* fi = tsd->callStackFrameInfo[frameIdx]; + void** addrs = tsd->callStackLocalsAddresses[frameIdx]; + if (fi == NULL || addrs == NULL) { + pthread_mutex_unlock(&s->mu); + uint32_t zero = 0; + sendEvent(EVT_LOCALS, &zero, 4); + return; + } + int count = fi->varTableCount; + size_t sz = 4 + (size_t)count * (4 + 1 + 8); + uint8_t* buf = (uint8_t*)malloc(sz); + writeBE32(buf, (uint32_t)count); + uint8_t* p = buf + 4; + for (int i = 0; i < count; i++) { + const struct cn1_var_entry* v = &fi->varTable[i]; + writeBE32(p, (uint32_t)v->slot); p += 4; + uint8_t tag = (uint8_t)v->typeCode; + uint64_t value = 0; + if (v->slot >= 0 && v->slot < fi->numLocals && addrs[v->slot] != NULL) { + switch (v->typeCode) { + case 'I': case 'B': case 'S': case 'C': case 'Z': + value = (uint64_t)(*(int32_t*)addrs[v->slot]); + break; + case 'J': + value = (uint64_t)(*(int64_t*)addrs[v->slot]); + break; + case 'F': { + float f = *(float*)addrs[v->slot]; + uint32_t u; memcpy(&u, &f, 4); + value = u; + break; + } + case 'D': { + double d = *(double*)addrs[v->slot]; + uint64_t u; memcpy(&u, &d, 8); + value = u; + break; + } + case 'L': case '[': { + JAVA_OBJECT obj = *(JAVA_OBJECT*)addrs[v->slot]; + value = (uint64_t)(uintptr_t)obj; + // Tag java.lang.String references with JDWP type 's' + // so the IDE can read their contents via + // StringReference.Value instead of invoking toString(). + if (v->typeCode == 'L' && obj != JAVA_NULL + && obj->__codenameOneParentClsReference == &class__java_lang_String) { + tag = 's'; + } + break; + } + } + } + *p++ = tag; + writeBE64(p, value); p += 8; + } + pthread_mutex_unlock(&s->mu); + sendEvent(EVT_LOCALS, buf, (uint32_t)sz); + free(buf); +} + +/* --------------------------------------------------------------------- */ +/* Listener thread. */ +/* --------------------------------------------------------------------- */ + +static int handleCommand(uint8_t cmd, const uint8_t* payload, uint32_t len) { + switch (cmd) { + case CMD_SET_BREAKPOINT: { + if (len < 8) return 0; + uint32_t mid, line; + memcpy(&mid, payload, 4); + memcpy(&line, payload + 4, 4); + bp_add((int)ntohl(mid), (int)ntohl(line)); + return 0; + } + case CMD_CLEAR_BREAKPOINT: { + if (len < 8) return 0; + uint32_t mid, line; + memcpy(&mid, payload, 4); + memcpy(&line, payload + 4, 4); + bp_clear((int)ntohl(mid), (int)ntohl(line)); + return 0; + } + case CMD_RESUME: { + // Preserve any pending step kind so a SINGLE_STEP request + // followed by VM.Resume keeps the step active for the next line. + if (len < 8) { + resumeAll(/*preserveStep*/ 1); + } else { + uint32_t hi, lo; + memcpy(&hi, payload, 4); + memcpy(&lo, payload + 4, 4); + int64_t tid = ((int64_t)ntohl(hi) << 32) | (uint32_t)ntohl(lo); + if (tid == 0) resumeAll(1); else resumeThreadById(tid, 1); + } + // The first RESUME after attach also releases cn1_debugger_start + // if it was waiting. + pthread_mutex_lock(&g_attachMutex); + g_waitForAttach = 0; + pthread_cond_broadcast(&g_attachCond); + pthread_mutex_unlock(&g_attachMutex); + return 0; + } + case CMD_STEP: { + if (len < 9) return 0; + uint32_t hi, lo; + memcpy(&hi, payload, 4); + memcpy(&lo, payload + 4, 4); + int64_t tid = ((int64_t)ntohl(hi) << 32) | (uint32_t)ntohl(lo); + uint8_t kind = payload[8]; + setStepAndResume(tid, (int)kind); + return 0; + } + case CMD_GET_STACK: { + if (len < 8) return 0; + uint32_t hi, lo; + memcpy(&hi, payload, 4); + memcpy(&lo, payload + 4, 4); + int64_t tid = ((int64_t)ntohl(hi) << 32) | (uint32_t)ntohl(lo); + handleGetStack(tid); + return 0; + } + case CMD_GET_LOCALS: { + if (len < 12) return 0; + uint32_t hi, lo, frame; + memcpy(&hi, payload, 4); + memcpy(&lo, payload + 4, 4); + memcpy(&frame, payload + 8, 4); + int64_t tid = ((int64_t)ntohl(hi) << 32) | (uint32_t)ntohl(lo); + handleGetLocals(tid, (int)ntohl(frame)); + return 0; + } + case CMD_DISPOSE: { + cn1DebuggerActive = 0; + resumeAll(/*preserveStep*/ 0); + return -1; // tells listener to tear down + } + case CMD_GET_OBJECT_CLASS: { + if (len < 8) { + sendEvent(EVT_OBJECT_CLASS, NULL, 0); + return 0; + } + uint32_t hi, lo; + memcpy(&hi, payload, 4); + memcpy(&lo, payload + 4, 4); + uint64_t ptr = ((uint64_t)ntohl(hi) << 32) | (uint32_t)ntohl(lo); + int classId = -1; + JAVA_OBJECT obj = (JAVA_OBJECT)(uintptr_t)ptr; + if (obj != JAVA_NULL && obj->__codenameOneParentClsReference != NULL) { + classId = obj->__codenameOneParentClsReference->classId; + } + uint8_t reply[4]; + uint32_t cidBE = htonl((uint32_t)classId); + memcpy(reply, &cidBE, 4); + sendEvent(EVT_OBJECT_CLASS, reply, 4); + return 0; + } + case CMD_GET_STRING: { + if (len < 8) { + sendEvent(EVT_STRING_VALUE, NULL, 0); + return 0; + } + uint32_t hi, lo; + memcpy(&hi, payload, 4); + memcpy(&lo, payload + 4, 4); + uint64_t ptr = ((uint64_t)ntohl(hi) << 32) | (uint32_t)ntohl(lo); + JAVA_OBJECT obj = (JAVA_OBJECT)(uintptr_t)ptr; + NSString* ns = nil; + @try { + if (obj != JAVA_NULL) { + ns = toNSString(getThreadLocalData(), obj); + } + } @catch (NSException* e) { + ns = nil; + } + const char* utf8 = ns ? [ns UTF8String] : ""; + size_t n = strlen(utf8); + sendEvent(EVT_STRING_VALUE, utf8, (uint32_t)n); + return 0; + } + case CMD_GET_THREADS: + case CMD_SUSPEND: + // Minimal viable: reply empty so the proxy doesn't hang. + sendEvent(EVT_REPLY_STATUS, NULL, 0); + return 0; + default: + NSLog(@"cn1_debugger: unknown command 0x%02x", cmd); + return 0; + } +} + +static void* listenerThreadMain(void* arg) { + @autoreleasepool { + NSDictionary* info = [[NSBundle mainBundle] infoDictionary]; + NSString* host = info[@"CN1ProxyHost"]; + NSNumber* portN = info[@"CN1ProxyPort"]; + int port = portN ? [portN intValue] : 55333; + if (!host) { + NSLog(@"cn1_debugger: CN1ProxyHost not configured"); + return NULL; + } + + // Retry a few times with backoff so launching slightly before the + // proxy is up still works. + int fd = -1; + for (int attempt = 0; attempt < 20; attempt++) { + fd = socket(AF_INET, SOCK_STREAM, 0); + if (fd < 0) { usleep(500000); continue; } + struct sockaddr_in sa; + memset(&sa, 0, sizeof(sa)); + sa.sin_family = AF_INET; + sa.sin_port = htons(port); + if (inet_pton(AF_INET, [host UTF8String], &sa.sin_addr) != 1) { + // Try resolving as a hostname. + struct addrinfo hints = {0}, *res = NULL; + hints.ai_family = AF_INET; + hints.ai_socktype = SOCK_STREAM; + if (getaddrinfo([host UTF8String], NULL, &hints, &res) == 0 && res) { + sa.sin_addr = ((struct sockaddr_in*)res->ai_addr)->sin_addr; + freeaddrinfo(res); + } else { + close(fd); + fd = -1; + usleep(500000); + continue; + } + } + if (connect(fd, (struct sockaddr*)&sa, sizeof(sa)) == 0) { + break; + } + close(fd); + fd = -1; + usleep(500000); + } + if (fd < 0) { + NSLog(@"cn1_debugger: could not connect to %@:%d, giving up", host, port); + pthread_mutex_lock(&g_attachMutex); + g_waitForAttach = 0; + pthread_cond_broadcast(&g_attachCond); + pthread_mutex_unlock(&g_attachMutex); + return NULL; + } + int nodelay = 1; + setsockopt(fd, IPPROTO_TCP, TCP_NODELAY, &nodelay, sizeof(nodelay)); + g_proxyFd = fd; + + // Send HELLO. + uint8_t hello[3]; + hello[0] = 0; + hello[1] = 0; + hello[2] = CN1_DBG_PROTOCOL_VERSION; + sendEvent(EVT_HELLO, hello, 3); + cn1DebuggerActive = 1; + NSLog(@"cn1_debugger: connected to proxy %@:%d (proto v%d)", host, port, CN1_DBG_PROTOCOL_VERSION); + + for (;;) { + uint32_t lenBE; + if (readAll(fd, &lenBE, 4) < 0) break; + uint32_t plen = ntohl(lenBE); + if (plen > (1 << 20)) { // sanity cap + NSLog(@"cn1_debugger: oversized payload %u, closing", plen); + break; + } + uint8_t cmd; + if (readAll(fd, &cmd, 1) < 0) break; + uint8_t* payload = NULL; + if (plen > 0) { + payload = (uint8_t*)malloc(plen); + if (readAll(fd, payload, plen) < 0) { free(payload); break; } + } + int rc = handleCommand(cmd, payload, plen); + free(payload); + if (rc < 0) break; + } + + NSLog(@"cn1_debugger: proxy connection closed"); + cn1DebuggerActive = 0; + resumeAll(-1); + close(fd); + g_proxyFd = -1; + pthread_mutex_lock(&g_attachMutex); + g_waitForAttach = 0; + pthread_cond_broadcast(&g_attachCond); + pthread_mutex_unlock(&g_attachMutex); + } + return NULL; +} + +void cn1_debugger_start(void) { + NSDictionary* info = [[NSBundle mainBundle] infoDictionary]; + NSString* host = info[@"CN1ProxyHost"]; + if (!host) { + NSLog(@"cn1_debugger: CN1ProxyHost not set in Info.plist; on-device-debug disabled at runtime"); + return; + } + NSNumber* wait = info[@"CN1ProxyWaitForAttach"]; + g_waitForAttach = (wait && [wait boolValue]) ? 1 : 0; + + pthread_t t; + pthread_create(&t, NULL, listenerThreadMain, NULL); + pthread_detach(t); + + if (g_waitForAttach) { + NSLog(@"cn1_debugger: waiting for proxy to attach and send RESUME..."); + pthread_mutex_lock(&g_attachMutex); + while (g_waitForAttach) { + pthread_cond_wait(&g_attachCond, &g_attachMutex); + } + pthread_mutex_unlock(&g_attachMutex); + NSLog(@"cn1_debugger: attach handshake complete, continuing boot"); + } +} + +#endif // CN1_ON_DEVICE_DEBUG diff --git a/docs/developer-guide/On-Device-Debugging.asciidoc b/docs/developer-guide/On-Device-Debugging.asciidoc new file mode 100644 index 0000000000..41c81df8ea --- /dev/null +++ b/docs/developer-guide/On-Device-Debugging.asciidoc @@ -0,0 +1,224 @@ +== On-Device Debugging (iOS) + +The Codename One simulator runs your app on the JVM, so the IDE's +normal Java debugger works against it directly. Some bugs only appear +on a real device — ParparVM's threading model, iOS-specific native +behaviour, performance characteristics on real hardware, memory +pressure under iOS background limits, and timing around UIKit +interactions are common examples. On-device debugging lets you keep +using a standard Java debugger (jdb, IntelliJ IDEA, VS Code, Eclipse, +NetBeans, anything that speaks JDWP) against the running iOS app on +either the iOS Simulator or a physical device. + +It works by adding a tiny listener thread to the ParparVM-generated +iOS binary. The app dials out to a desktop proxy over Wi-Fi/loopback; +the proxy speaks JDWP on the other side, so to the IDE everything +looks like attaching to a normal remote JVM. + +=== When to use it + +* You can reproduce a bug on the simulator's Xcode build but not in + the JavaSE simulator — for instance a native-call issue, a layout + glitch that only shows up on iOS modern theme, or a JIT-vs.-AOT + numerical discrepancy. +* You want to single-step through real ParparVM-translated code and + read the values its variables actually hold at runtime. +* You want to inspect object state on a tethered device while + reproducing a problem a customer reported, without having to add + log lines and rebuild. + +If your bug is reproducible in the simulator, stay in the simulator — +its debugger is faster and has fewer limitations. + +=== Prerequisites + +* Codename One {cn1-release-version} or later. +* macOS with Xcode installed. The iOS app must be built locally + (Xcode Simulator or a tethered device), or via the cloud build + configured with the on-device-debug target. +* A JDWP-capable debugger. `jdb` ships with the JDK; IntelliJ + IDEA, VS Code (Debugger for Java), Eclipse and NetBeans all work. +* For physical devices: the device and your laptop need to be on the + same Wi-Fi network. For iOS Simulator: localhost is sufficient. + +=== Quick start + +==== 1. Enable the build hint + +Add to your project's `common/codenameone_settings.properties`: + +[source,properties] +---- +codename1.arg.ios.onDeviceDebug=true +codename1.arg.ios.onDeviceDebug.proxyHost=127.0.0.1 +codename1.arg.ios.onDeviceDebug.proxyPort=55333 +# Optional: block the app at startup until the proxy is attached. +# codename1.arg.ios.onDeviceDebug.waitForAttach=true +---- + +For a physical device, set `proxyHost` to the laptop's LAN IP +(`ifconfig | grep "inet "`) instead of `127.0.0.1`. + +==== 2. Build the iOS app + +Locally: + +[source,bash] +---- +mvn package -DskipTests \ + -Dcodename1.platform=ios \ + -Dcodename1.buildTarget=ios-source +---- + +This generates an Xcode project under `ios/target/...-ios-source/`. +Open `.xcodeproj` in Xcode and run on the iOS Simulator +or a tethered device. + +Via the cloud build, use the `ios-on-device-debug` build target: + +[source,bash] +---- +mvn package -DskipTests \ + -Dcodename1.platform=ios \ + -Dcodename1.buildTarget=ios-on-device-debug +---- + +==== 3. Start the proxy + +[source,bash] +---- +mvn cn1:ios-on-device-debugging +---- + +The proxy prints two listening ports and waits for both the device +and a debugger to connect: + +---- +On-device-debug proxy starting: + symbols : .../cn1-symbols.txt + device : listening on tcp://0.0.0.0:55333 + jdwp : listening on tcp://0.0.0.0:8000 + +Next steps: + 1. Open the generated Xcode project in iOS Simulator or on device. + 2. Once the app boots it will dial in and the proxy will log a HELLO. + 3. Attach a debugger: jdb -attach localhost:8000 +---- + +==== 4. Attach a debugger + +From a second terminal, point your debugger at the proxy's JDWP port. + +For `jdb`: + +[source,bash] +---- +jdb -attach localhost:8000 +---- + +For IntelliJ IDEA: *Run → Edit Configurations…* → *+* → *Remote JVM +Debug*. Set the host to `localhost`, port to `8000`, leave the rest +at the defaults, and click *Debug*. + +For VS Code (Debugger for Java extension): add a launch +configuration of type `java` with `"request": "attach"`, `"hostName": +"localhost"`, `"port": 8000`. + +When the app starts running on the device it will connect to the +proxy and the IDE will see a fresh suspended VM. Set breakpoints, +step, inspect, just like a normal remote attach. + +=== What works today + +* Class loading: the IDE sees every class in your build. +* Line breakpoints in user code and in the Codename One framework. +* Step into / over / out. +* Stack walking — full Java stack including the cn1 framework + frames above your code. +* Inspecting primitive locals (int, long, float, double, boolean, + byte, char, short). +* Inspecting `java.lang.String` values directly. +* Inspecting object references — class name and identity, plus the + ability to drill in via the IDE's variables view. +* Pause and resume from the IDE. + +=== Known limitations + +* *Method invocation from the debugger is not supported.* The IDE's + "evaluate expression" feature can read existing values but cannot + invoke methods on objects (e.g. `myList.size()`). Object identity, + field access, and primitive evaluation are supported. +* *Watch expressions* that don't require method calls work; those + that do return an error. +* *Hot-swap* is not supported. Code changes require a rebuild and + reinstall of the app. +* *Hot threads* — if your app is doing heavy work on a non-EDT + thread when a breakpoint hits, that other thread continues + running. Only the thread that hit the breakpoint suspends by + default. Use the IDE's "suspend VM" if you need a fully frozen + picture. +* *Local variable names* are present only when the user's Java + sources were compiled with `-g`. Codename One's archetypes do + compile with debug info by default; if a variable shows up as + `v1`, `v2`, ... it means that particular class was compiled + without debug info. + +=== Performance + +The on-device-debug instrumentation is gated by a compile-time flag +that is *only set in debug builds*. Release builds (`ios-release`) +are completely unaffected — no listener thread, no per-line callback, +no extra metadata in the binary. + +In debug builds, the per-line callback adds a predictable +load+branch around the macro that ParparVM already emits to record +source line numbers for stack traces. When no debugger is attached +the runtime cost is close to zero. When a debugger *is* attached, +expect numerical inner loops to run on the order of two to three +times slower than a release build — this is normal and matches the +overhead of `-g`-style debugging on other native VMs. + +=== Troubleshooting + +==== The proxy reports "device disconnected" before my breakpoint fires + +The app may be crashing before it reaches user code. Run the app +from Xcode and watch the console — installSignalHandlers will +intercept native crashes and rethrow them as Java exceptions you can +see. If the connection drops mid-debug, check the proxy log for a +device-side I/O error. + +==== "Connection refused" when the device tries to reach the proxy + +The device's `CN1ProxyHost` is wrong, or a firewall is blocking the +port. Verify: + +* The simulator can always reach `127.0.0.1`. +* A physical device needs your laptop's *LAN IP* (not `localhost`), + and the laptop's firewall must allow incoming connections on the + configured `proxyPort` (default `55333`). +* macOS may prompt the first time the proxy listens — accept the + Allow incoming connections dialog. + +==== jdb reports "Internal exception: Unexpected JDWP Error: 100" + +This typically means an unsupported JDWP feature was invoked — most +commonly an attempt to call a method on an object from the debugger +(see "Method invocation from the debugger is not supported" above). +The debug session itself is fine; rerun the operation as a value +read (e.g. expand the object in the variables view) instead of a +method call. + +==== The app launches but the breakpoint never fires + +Confirm the build hint actually took effect. Inspect the generated +Xcode project's `cn1_globals.h` and look for `#define +CN1_ON_DEVICE_DEBUG` *without* a leading `//`. If the line is still +commented, the build was a release/non-debug build, or +`ios.onDeviceDebug` wasn't set in `codenameone_settings.properties`. + +==== I want the app to wait for me to attach before running + +Set `codename1.arg.ios.onDeviceDebug.waitForAttach=true`. The app +will block at startup until the proxy is connected and the IDE has +issued a Resume. diff --git a/docs/developer-guide/developer-guide.asciidoc b/docs/developer-guide/developer-guide.asciidoc index 635d2e90d9..8ca50862bb 100644 --- a/docs/developer-guide/developer-guide.asciidoc +++ b/docs/developer-guide/developer-guide.asciidoc @@ -88,6 +88,8 @@ include::signing.asciidoc[] include::Working-With-iOS.asciidoc[] +include::On-Device-Debugging.asciidoc[] + include::Working-With-Javascript.asciidoc[] include::Working-with-Mac-OS-X.asciidoc[] diff --git a/maven/cn1-debug-proxy/pom.xml b/maven/cn1-debug-proxy/pom.xml new file mode 100644 index 0000000000..8f18477aca --- /dev/null +++ b/maven/cn1-debug-proxy/pom.xml @@ -0,0 +1,54 @@ + + + + + com.codenameone + codenameone + 8.0-SNAPSHOT + + 4.0.0 + + cn1-debug-proxy + 8.0-SNAPSHOT + jar + cn1-debug-proxy + + Desktop proxy for Codename One on-device-debugging. Bridges between + a JDWP-speaking debugger (jdb, IntelliJ, etc.) on one socket and the + ParparVM-built iOS app's custom debug protocol on another. + + + + + + maven-compiler-plugin + + 1.8 + 1.8 + + + + org.apache.maven.plugins + maven-shade-plugin + + + package + + shade + + + + + com.codename1.debug.proxy.ProxyMain + + + false + true + standalone + + + + + + + diff --git a/maven/cn1-debug-proxy/src/main/java/com/codename1/debug/proxy/DeviceConnection.java b/maven/cn1-debug-proxy/src/main/java/com/codename1/debug/proxy/DeviceConnection.java new file mode 100644 index 0000000000..b34b5c0c04 --- /dev/null +++ b/maven/cn1-debug-proxy/src/main/java/com/codename1/debug/proxy/DeviceConnection.java @@ -0,0 +1,267 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + */ +package com.codename1.debug.proxy; + +import java.io.DataInputStream; +import java.io.DataOutputStream; +import java.io.IOException; +import java.net.ServerSocket; +import java.net.Socket; +import java.util.concurrent.atomic.AtomicBoolean; + +/** + * Manages the TCP connection from a Codename One iOS app's on-device-debug + * runtime ({@link cn1_debugger.m}). The app dials out to the developer's + * laptop, so this side listens for a single accept then services events + * and commands. + * + * Events arrive from the device; this class decodes the wire frame and + * dispatches to a {@link DeviceListener}. The listener (typically the + * JDWP server) calls back into {@link #sendCommand} to manipulate + * breakpoints, resume threads, request stack/locals, etc. + * + * Commands and events use the same length-prefixed framing — see + * {@link WireProtocol} for codes and payload layouts. + */ +public final class DeviceConnection implements AutoCloseable { + + public interface DeviceListener { + void onHello(int version); + void onBreakpointHit(long threadId, int methodId, int line); + void onStepComplete(long threadId, int methodId, int line); + void onStack(long threadId, int[] methodIds, int[] lines); + void onLocals(int[] slots, byte[] typeCodes, long[] values); + void onVmDeath(); + void onStringValue(String value); + void onObjectClass(int classId); + void onReplyStatus(); + void onUnknownEvent(int code, byte[] payload); + void onDisconnected(); + } + + private final int listenPort; + private final DeviceListener listener; + private final AtomicBoolean closed = new AtomicBoolean(false); + private volatile ServerSocket server; + private volatile Socket socket; + private volatile DataInputStream in; + private volatile DataOutputStream out; + private final Object sendLock = new Object(); + + public DeviceConnection(int listenPort, DeviceListener listener) { + this.listenPort = listenPort; + this.listener = listener; + } + + public void acceptAndServe() throws IOException { + server = new ServerSocket(listenPort); + System.out.println("[device] listening on port " + listenPort + " for ParparVM app to dial in"); + socket = server.accept(); + socket.setTcpNoDelay(true); + in = new DataInputStream(socket.getInputStream()); + out = new DataOutputStream(socket.getOutputStream()); + System.out.println("[device] connected from " + socket.getRemoteSocketAddress()); + // We've taken the one connection we need; close the listener so + // a stray reconnect attempt fails fast instead of stacking up. + try { server.close(); } catch (IOException ignore) {} + + try { + readLoop(); + } finally { + close(); + listener.onDisconnected(); + } + } + + private void readLoop() throws IOException { + while (!closed.get()) { + int payloadLen = in.readInt(); // big-endian by DataInput contract + if (payloadLen < 0 || payloadLen > (1 << 20)) { + throw new IOException("Unreasonable payload length " + payloadLen); + } + int code = in.readUnsignedByte(); + byte[] payload = new byte[payloadLen]; + in.readFully(payload); + dispatch(code, payload); + } + } + + private void dispatch(int code, byte[] p) { + switch (code) { + case WireProtocol.EVT_HELLO: { + int ver = p.length >= 3 ? p[2] & 0xff : 0; + listener.onHello(ver); + return; + } + case WireProtocol.EVT_BP_HIT: { + if (p.length < 16) { listener.onUnknownEvent(code, p); return; } + long tid = readLong(p, 0); + int mid = readInt(p, 8); + int line = readInt(p, 12); + listener.onBreakpointHit(tid, mid, line); + return; + } + case WireProtocol.EVT_STEP_COMPLETE: { + if (p.length < 16) { listener.onUnknownEvent(code, p); return; } + long tid = readLong(p, 0); + int mid = readInt(p, 8); + int line = readInt(p, 12); + listener.onStepComplete(tid, mid, line); + return; + } + case WireProtocol.EVT_STACK: { + if (p.length < 12) { listener.onUnknownEvent(code, p); return; } + long tid = readLong(p, 0); + int n = readInt(p, 8); + int[] mids = new int[n]; + int[] lines = new int[n]; + for (int i = 0; i < n; i++) { + mids[i] = readInt(p, 12 + i * 8); + lines[i] = readInt(p, 12 + i * 8 + 4); + } + listener.onStack(tid, mids, lines); + return; + } + case WireProtocol.EVT_LOCALS: { + if (p.length < 4) { listener.onUnknownEvent(code, p); return; } + int n = readInt(p, 0); + int[] slots = new int[n]; + byte[] types = new byte[n]; + long[] values = new long[n]; + int off = 4; + for (int i = 0; i < n; i++) { + slots[i] = readInt(p, off); off += 4; + types[i] = p[off]; off += 1; + values[i] = readLong(p, off); off += 8; + } + listener.onLocals(slots, types, values); + return; + } + case WireProtocol.EVT_VM_DEATH: + listener.onVmDeath(); + return; + case WireProtocol.EVT_STRING_VALUE: { + String s = new String(p, java.nio.charset.StandardCharsets.UTF_8); + listener.onStringValue(s); + return; + } + case WireProtocol.EVT_OBJECT_CLASS: { + int cid = p.length >= 4 ? readInt(p, 0) : -1; + listener.onObjectClass(cid); + return; + } + case WireProtocol.EVT_REPLY_STATUS: + listener.onReplyStatus(); + return; + default: + listener.onUnknownEvent(code, p); + } + } + + public void sendCommand(int cmd, byte[] payload) throws IOException { + synchronized (sendLock) { + if (out == null) throw new IOException("Device not connected"); + int len = payload == null ? 0 : payload.length; + out.writeInt(len); + out.writeByte(cmd); + if (len > 0) out.write(payload); + out.flush(); + } + } + + public void setBreakpoint(int methodId, int line) throws IOException { + byte[] p = new byte[8]; + writeInt(p, 0, methodId); + writeInt(p, 4, line); + sendCommand(WireProtocol.CMD_SET_BREAKPOINT, p); + } + + public void clearBreakpoint(int methodId, int line) throws IOException { + byte[] p = new byte[8]; + writeInt(p, 0, methodId); + writeInt(p, 4, line); + sendCommand(WireProtocol.CMD_CLEAR_BREAKPOINT, p); + } + + public void resume(long threadId) throws IOException { + byte[] p = new byte[8]; + writeLong(p, 0, threadId); + sendCommand(WireProtocol.CMD_RESUME, p); + } + + public void resumeAll() throws IOException { + sendCommand(WireProtocol.CMD_RESUME, new byte[8]); + } + + public void step(long threadId, int stepKind) throws IOException { + byte[] p = new byte[9]; + writeLong(p, 0, threadId); + p[8] = (byte) stepKind; + sendCommand(WireProtocol.CMD_STEP, p); + } + + public void getStack(long threadId) throws IOException { + byte[] p = new byte[8]; + writeLong(p, 0, threadId); + sendCommand(WireProtocol.CMD_GET_STACK, p); + } + + public void getLocals(long threadId, int frameOffsetFromTop) throws IOException { + byte[] p = new byte[12]; + writeLong(p, 0, threadId); + writeInt(p, 8, frameOffsetFromTop); + sendCommand(WireProtocol.CMD_GET_LOCALS, p); + } + + public void dispose() throws IOException { + sendCommand(WireProtocol.CMD_DISPOSE, null); + } + + public void getObjectClass(long objPtr) throws IOException { + byte[] p = new byte[8]; + writeLong(p, 0, objPtr); + sendCommand(WireProtocol.CMD_GET_OBJECT_CLASS, p); + } + + public void getString(long objPtr) throws IOException { + byte[] p = new byte[8]; + writeLong(p, 0, objPtr); + sendCommand(WireProtocol.CMD_GET_STRING, p); + } + + @Override + public void close() { + if (closed.compareAndSet(false, true)) { + try { if (socket != null) socket.close(); } catch (IOException ignore) {} + try { if (server != null) server.close(); } catch (IOException ignore) {} + } + } + + private static int readInt(byte[] b, int off) { + return ((b[off] & 0xff) << 24) | ((b[off + 1] & 0xff) << 16) + | ((b[off + 2] & 0xff) << 8) | (b[off + 3] & 0xff); + } + + private static long readLong(byte[] b, int off) { + return ((long) readInt(b, off) << 32) | (readInt(b, off + 4) & 0xffffffffL); + } + + private static void writeInt(byte[] b, int off, int v) { + b[off] = (byte)(v >>> 24); + b[off+1] = (byte)(v >>> 16); + b[off+2] = (byte)(v >>> 8); + b[off+3] = (byte)v; + } + + private static void writeLong(byte[] b, int off, long v) { + writeInt(b, off, (int)(v >>> 32)); + writeInt(b, off + 4, (int) v); + } +} diff --git a/maven/cn1-debug-proxy/src/main/java/com/codename1/debug/proxy/JdwpServer.java b/maven/cn1-debug-proxy/src/main/java/com/codename1/debug/proxy/JdwpServer.java new file mode 100644 index 0000000000..2243ecade3 --- /dev/null +++ b/maven/cn1-debug-proxy/src/main/java/com/codename1/debug/proxy/JdwpServer.java @@ -0,0 +1,1096 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + */ +package com.codename1.debug.proxy; + +import java.io.DataInputStream; +import java.io.DataOutputStream; +import java.io.IOException; +import java.net.ServerSocket; +import java.net.Socket; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicInteger; + +/** + * Minimum-viable JDWP server. Speaks enough of the protocol that jdb can + * connect, set a line breakpoint, see it fire, walk the stack, and read + * primitive locals — which is the bar we're aiming for in this first cut. + * + * Many JDWP commands are answered with empty / "no capability" replies so + * the IDE doesn't hang; expanding the surface (object inspection, eval, + * field reads, ARM events) is a follow-up. + * + * IDs: typeID = classId from the sidecar, methodID = methodId from the + * sidecar, both padded to 8 bytes. threadID = the ParparVM threadId. + * frameID = (threadId << 32) | frameIdxFromTop. The "bytecode index" + * inside a JDWP Location is just the source line — we don't carry real + * bytecode offsets in ParparVM, and jdb uses the index purely to look + * the same value back up via Method.LineTable. + */ +public final class JdwpServer implements DeviceConnection.DeviceListener { + + private static final String HANDSHAKE = "JDWP-Handshake"; + + // JDWP command sets + private static final int CS_VIRTUAL_MACHINE = 1; + private static final int CS_REFERENCE_TYPE = 2; + private static final int CS_CLASS_TYPE = 3; + private static final int CS_METHOD = 6; + private static final int CS_OBJECT_REFERENCE = 9; + private static final int CS_STRING_REFERENCE = 10; + private static final int CS_THREAD_REFERENCE = 11; + private static final int CS_THREAD_GROUP_REF = 12; + private static final int CS_ARRAY_REFERENCE = 13; + private static final int CS_CLASS_LOADER_REF = 14; + private static final int CS_EVENT_REQUEST = 15; + private static final int CS_STACK_FRAME = 16; + private static final int CS_CLASS_OBJECT_REF = 17; + private static final int CS_EVENT = 64; + + // Event kinds (subset) + private static final int EK_SINGLE_STEP = 1; + private static final int EK_BREAKPOINT = 2; + private static final int EK_THREAD_START = 6; + private static final int EK_THREAD_DEATH = 7; + private static final int EK_CLASS_PREPARE = 8; + private static final int EK_CLASS_UNLOAD = 9; + private static final int EK_VM_START = 90; + private static final int EK_VM_DEATH = 99; + + // refTypeTag + private static final int TYPE_TAG_CLASS = 1; + + // SuspendPolicy + private static final int SP_NONE = 0; + private static final int SP_EVENT_THREAD = 1; + private static final int SP_ALL = 2; + + private final int port; + private final SymbolTable symbols; + private volatile DeviceConnection device; + + private Socket socket; + private DataInputStream in; + private DataOutputStream out; + private final Object writeLock = new Object(); + + private final AtomicInteger nextRequestId = new AtomicInteger(1); + private final Map bpRequests = new ConcurrentHashMap<>(); + // Pending step request keyed by threadId so the device's STEP_COMPLETE + // event can find the JDWP request ID that triggered it. + private final Map stepRequests = new ConcurrentHashMap<>(); + private final List knownThreads = new ArrayList<>(); + // Synthetic thread group ID. jdb wants a non-null group on every thread. + private static final long FAKE_GROUP_ID = 0xCAFEL; + private static final long FAKE_CLASSLOADER_ID = 0; + // Last suspending event's thread, used by ThreadReference.Frames when + // no explicit per-thread bookkeeping has been done yet. + private volatile long lastSuspendedThread = 0; + + /* + * JDWP treats reference-type and method IDs of 0 as "null", but the + * translator's classId/methodId space is dense starting at 0. We shift + * both directions by +1 at the JDWP boundary so id 0 stays reserved. + */ + private static long toJdwpRef(int sidecarId) { return sidecarId + 1L; } + private static int fromJdwpRef(long jdwpId) { return (int)(jdwpId - 1L); } + // True once the device has handshook; the VM_START event will fire as + // soon as a JDWP client is attached (and was already attached when the + // device joined — order doesn't matter). + private volatile boolean deviceHelloReceived = false; + + private static final class BpRequest { + final int requestId; + final long typeID; // classId + final long methodID; + final long codeIndex; // source line + final int suspendPolicy; + BpRequest(int rid, long typeID, long methodID, long codeIndex, int sp) { + this.requestId = rid; this.typeID = typeID; this.methodID = methodID; + this.codeIndex = codeIndex; this.suspendPolicy = sp; + } + } + + public JdwpServer(int port, SymbolTable symbols) { + this.port = port; + this.symbols = symbols; + } + + public void setDevice(DeviceConnection device) { + this.device = device; + } + + public void acceptAndServe() throws IOException { + try (ServerSocket server = new ServerSocket(port)) { + System.out.println("[jdwp] listening on port " + port + " for debugger (jdb) to attach"); + socket = server.accept(); + } + socket.setTcpNoDelay(true); + in = new DataInputStream(socket.getInputStream()); + out = new DataOutputStream(socket.getOutputStream()); + System.out.println("[jdwp] debugger connected from " + socket.getRemoteSocketAddress()); + + if (!doHandshake()) { + System.err.println("[jdwp] handshake failed"); + return; + } + System.out.println("[jdwp] handshake complete"); + // If the device connected before us, fire VM_START now. + if (deviceHelloReceived) { + sendVmStart(); + } + + try { + packetLoop(); + } finally { + try { socket.close(); } catch (IOException ignore) {} + } + } + + private void sendVmStart() { + try { + Buf b = new Buf(); + b.writeByte(SP_NONE); + b.writeInt(1); + b.writeByte(EK_VM_START); + b.writeInt(0); // requestID 0 = auto-generated + b.writeLong(1); // dummy thread id; jdb tolerates this + writeEventCommand(b.bytes()); + } catch (IOException e) { + System.err.println("[jdwp] failed to send VM_START: " + e.getMessage()); + } + } + + private boolean doHandshake() throws IOException { + byte[] expected = HANDSHAKE.getBytes(StandardCharsets.US_ASCII); + byte[] buf = new byte[expected.length]; + in.readFully(buf); + for (int i = 0; i < expected.length; i++) { + if (buf[i] != expected[i]) return false; + } + out.write(expected); + out.flush(); + return true; + } + + private void packetLoop() throws IOException { + while (true) { + int len; + try { + len = in.readInt(); + } catch (IOException eof) { + System.out.println("[jdwp] debugger disconnected"); + return; + } + if (len < 11) throw new IOException("Malformed JDWP packet length=" + len); + int id = in.readInt(); + int flags = in.readUnsignedByte(); + byte[] payload = new byte[len - 11]; + if ((flags & 0x80) != 0) { + // Reply packet — we don't initiate commands TO jdb (yet), so ignore. + int err = in.readUnsignedShort(); + in.readFully(payload); + continue; + } + int cmdSet = in.readUnsignedByte(); + int cmd = in.readUnsignedByte(); + in.readFully(payload); + dispatchCommand(id, cmdSet, cmd, payload); + } + } + + // -------- Packet writers ------------------------------------------------ + + private void writeReply(int id, int errorCode, byte[] data) throws IOException { + synchronized (writeLock) { + int len = 11 + (data == null ? 0 : data.length); + out.writeInt(len); + out.writeInt(id); + out.writeByte(0x80); + out.writeShort(errorCode); + if (data != null && data.length > 0) out.write(data); + out.flush(); + } + } + + private void writeEventCommand(byte[] data) throws IOException { + synchronized (writeLock) { + int len = 11 + data.length; + out.writeInt(len); + out.writeInt(nextRequestId.incrementAndGet()); + out.writeByte(0x00); + out.writeByte(CS_EVENT); + out.writeByte(100); // Composite + out.write(data); + out.flush(); + } + } + + private static byte[] empty() { return new byte[0]; } + + // -------- Dispatch ------------------------------------------------------ + + private static final boolean LOG_JDWP = Boolean.getBoolean("cn1.debug.logJdwp"); + + private void dispatchCommand(int id, int cmdSet, int cmd, byte[] p) throws IOException { + if (LOG_JDWP) System.out.println("[jdwp<-] cmdSet=" + cmdSet + " cmd=" + cmd + " len=" + p.length); + try { + switch (cmdSet) { + case CS_VIRTUAL_MACHINE: handleVM(id, cmd, p); return; + case CS_REFERENCE_TYPE: handleRefType(id, cmd, p); return; + case CS_METHOD: handleMethod(id, cmd, p); return; + case CS_THREAD_REFERENCE: handleThread(id, cmd, p); return; + case CS_THREAD_GROUP_REF: handleThreadGroup(id, cmd, p); return; + case CS_STACK_FRAME: handleStackFrame(id, cmd, p); return; + case CS_EVENT_REQUEST: handleEventRequest(id, cmd, p); return; + case CS_OBJECT_REFERENCE: handleObject(id, cmd, p); return; + case CS_CLASS_TYPE: handleClassType(id, cmd, p); return; + case CS_STRING_REFERENCE: handleString(id, cmd, p); return; + case CS_ARRAY_REFERENCE: + case CS_CLASS_LOADER_REF: + case CS_CLASS_OBJECT_REF: + // Reply with NOT_IMPLEMENTED (100) so jdb falls back gracefully. + writeReply(id, 100, empty()); + return; + default: + writeReply(id, 100, empty()); + } + } catch (Throwable t) { + t.printStackTrace(); + writeReply(id, 103, empty()); // INTERNAL + } + } + + // -------- VirtualMachine ------------------------------------------------ + + private void handleVM(int id, int cmd, byte[] p) throws IOException { + switch (cmd) { + case 1: { // Version + Buf b = new Buf(); + b.writeString("Codename One ParparVM on-device-debug proxy"); + b.writeInt(1); b.writeInt(8); // JDWP 1.8 + b.writeString("1.8"); + b.writeString("ParparVM"); + writeReply(id, 0, b.bytes()); + return; + } + case 2: { // ClassesBySignature + String sig = readString(p, 0); + Buf b = new Buf(); + SymbolTable.ClassInfo c = symbols.classByJvmSignature(sig); + if (c != null) { + b.writeInt(1); + b.writeByte(TYPE_TAG_CLASS); + b.writeLong(toJdwpRef(c.classId)); + b.writeInt(7); // VERIFIED|PREPARED|INITIALIZED + } else { + b.writeInt(0); + } + writeReply(id, 0, b.bytes()); + return; + } + case 3: { // AllClasses + Buf b = new Buf(); + b.writeInt(symbols.allClasses().size()); + for (SymbolTable.ClassInfo c : symbols.allClasses()) { + b.writeByte(TYPE_TAG_CLASS); + b.writeLong(toJdwpRef(c.classId)); + b.writeString(c.jvmSignature()); + b.writeInt(7); + } + writeReply(id, 0, b.bytes()); + return; + } + case 20: { // AllClassesWithGeneric — extra empty generic-sig per class + Buf b = new Buf(); + b.writeInt(symbols.allClasses().size()); + for (SymbolTable.ClassInfo c : symbols.allClasses()) { + b.writeByte(TYPE_TAG_CLASS); + b.writeLong(toJdwpRef(c.classId)); + b.writeString(c.jvmSignature()); + b.writeString(""); // generic signature + b.writeInt(7); + } + writeReply(id, 0, b.bytes()); + return; + } + case 4: { // AllThreads + Buf b = new Buf(); + b.writeInt(knownThreads.size()); + for (long tid : knownThreads) b.writeLong(tid); + writeReply(id, 0, b.bytes()); + return; + } + case 5: { // TopLevelThreadGroups + Buf b = new Buf(); + b.writeInt(1); + b.writeLong(FAKE_GROUP_ID); + writeReply(id, 0, b.bytes()); + return; + } + case 6: { // Dispose + if (device != null) try { device.dispose(); } catch (IOException ignore) {} + writeReply(id, 0, empty()); + return; + } + case 7: { // IDSizes + Buf b = new Buf(); + b.writeInt(8); b.writeInt(8); b.writeInt(8); b.writeInt(8); b.writeInt(8); + writeReply(id, 0, b.bytes()); + return; + } + case 8: { // Suspend + writeReply(id, 0, empty()); + return; + } + case 9: { // Resume + if (device != null) try { device.resumeAll(); } catch (IOException ignore) {} + writeReply(id, 0, empty()); + return; + } + case 10: { // Exit + writeReply(id, 0, empty()); + return; + } + case 12: case 17: { // Capabilities / CapabilitiesNew — return all false + Buf b = new Buf(); + int n = (cmd == 12) ? 7 : 32; + for (int i = 0; i < n; i++) b.writeByte(0); + writeReply(id, 0, b.bytes()); + return; + } + case 13: { // ClassPaths + Buf b = new Buf(); + b.writeString("/"); // base dir + b.writeInt(0); b.writeInt(0); // empty classpath/bootclasspath + writeReply(id, 0, b.bytes()); + return; + } + default: + writeReply(id, 100, empty()); + } + } + + // -------- ReferenceType ------------------------------------------------- + + private void handleRefType(int id, int cmd, byte[] p) throws IOException { + long typeID = readLong(p, 0); + SymbolTable.ClassInfo c = symbols.classById(fromJdwpRef(typeID)); + switch (cmd) { + case 1: { // Signature + Buf b = new Buf(); + b.writeString(c != null ? c.jvmSignature() : "Ljava/lang/Object;"); + writeReply(id, 0, b.bytes()); + return; + } + case 2: { // ClassLoader + Buf b = new Buf(); + b.writeLong(FAKE_CLASSLOADER_ID); + writeReply(id, 0, b.bytes()); + return; + } + case 3: { // Modifiers + Buf b = new Buf(); b.writeInt(0x0001); writeReply(id, 0, b.bytes()); return; + } + case 4: { // Fields + Buf b = new Buf(); b.writeInt(0); writeReply(id, 0, b.bytes()); return; + } + case 14: { // FieldsWithGeneric — stubbed empty too + Buf b = new Buf(); b.writeInt(0); writeReply(id, 0, b.bytes()); return; + } + case 5: { // Methods + Buf b = new Buf(); + if (c == null) { b.writeInt(0); writeReply(id, 0, b.bytes()); return; } + b.writeInt(c.methods.size()); + for (SymbolTable.MethodInfo m : c.methods) { + b.writeLong(toJdwpRef(m.methodId)); + b.writeString(m.name); + b.writeString(m.descriptor); + b.writeInt(0x0001); // PUBLIC; we don't track real modifiers + } + writeReply(id, 0, b.bytes()); + return; + } + case 15: { // MethodsWithGeneric — same as Methods + per-method genericSig + Buf b = new Buf(); + if (c == null) { b.writeInt(0); writeReply(id, 0, b.bytes()); return; } + b.writeInt(c.methods.size()); + for (SymbolTable.MethodInfo m : c.methods) { + b.writeLong(toJdwpRef(m.methodId)); + b.writeString(m.name); + b.writeString(m.descriptor); + b.writeString(""); // generic signature + b.writeInt(0x0001); + } + writeReply(id, 0, b.bytes()); + return; + } + case 7: { // SourceFile + Buf b = new Buf(); + b.writeString(c != null && c.sourceFile != null && !c.sourceFile.isEmpty() + ? c.sourceFile : "Unknown.java"); + writeReply(id, 0, b.bytes()); + return; + } + case 9: { // Status + Buf b = new Buf(); b.writeInt(7); writeReply(id, 0, b.bytes()); return; + } + case 10: { // Interfaces + Buf b = new Buf(); b.writeInt(0); writeReply(id, 0, b.bytes()); return; + } + case 11: { // ClassObject + Buf b = new Buf(); b.writeLong(typeID); writeReply(id, 0, b.bytes()); return; + } + default: + writeReply(id, 100, empty()); + } + } + + // -------- Method -------------------------------------------------------- + + private void handleMethod(int id, int cmd, byte[] p) throws IOException { + long typeID = readLong(p, 0); + long methodID = readLong(p, 8); + SymbolTable.MethodInfo m = symbols.methodById(fromJdwpRef(methodID)); + switch (cmd) { + case 1: { // LineTable + Buf b = new Buf(); + if (m == null || m.lines.isEmpty()) { + b.writeLong(0); b.writeLong(0); b.writeInt(0); + } else { + long start = m.lines.first(); + long end = m.lines.last(); + b.writeLong(start); b.writeLong(end); b.writeInt(m.lines.size()); + for (int line : m.lines) { + b.writeLong(line); b.writeInt(line); + } + } + writeReply(id, 0, b.bytes()); + return; + } + case 2: { // VariableTable + Buf b = new Buf(); + if (m == null || m.locals.isEmpty()) { + b.writeInt(0); b.writeInt(0); + } else { + // argSlots without isStatic distinction — we don't track + // modifiers in the sidecar yet, so assume instance (off + // by one for static methods is harmless to jdb's display). + b.writeInt(m.argSlots(false)); + b.writeInt(m.locals.size()); + for (SymbolTable.LocalVarInfo v : m.locals) { + // codeIndex=0, length=large => "always live" + b.writeLong(0L); + b.writeString(v.name); + b.writeString(v.descriptor); + b.writeInt(Integer.MAX_VALUE); + b.writeInt(v.slot); + } + } + writeReply(id, 0, b.bytes()); + return; + } + case 5: { // VariableTableWithGeneric — adds per-var generic sig + Buf b = new Buf(); + if (m == null || m.locals.isEmpty()) { + b.writeInt(0); b.writeInt(0); + } else { + b.writeInt(m.argSlots(false)); + b.writeInt(m.locals.size()); + for (SymbolTable.LocalVarInfo v : m.locals) { + b.writeLong(0L); + b.writeString(v.name); + b.writeString(v.descriptor); + b.writeString(""); // generic signature + b.writeInt(Integer.MAX_VALUE); + b.writeInt(v.slot); + } + } + writeReply(id, 0, b.bytes()); + return; + } + case 3: { // Bytecodes + Buf b = new Buf(); b.writeInt(0); + writeReply(id, 0, b.bytes()); + return; + } + case 4: { // IsObsolete + Buf b = new Buf(); b.writeByte(0); writeReply(id, 0, b.bytes()); return; + } + default: + writeReply(id, 100, empty()); + } + } + + private void handleClassType(int id, int cmd, byte[] p) throws IOException { + switch (cmd) { + case 1: { // Superclass + Buf b = new Buf(); + b.writeLong(0); // null = java.lang.Object's super + writeReply(id, 0, b.bytes()); + return; + } + default: + writeReply(id, 100, empty()); + } + } + + // -------- Thread / ThreadGroup ----------------------------------------- + + private void handleThread(int id, int cmd, byte[] p) throws IOException { + long tid = readLong(p, 0); + switch (cmd) { + case 1: { // Name + Buf b = new Buf(); b.writeString("Thread-" + tid); writeReply(id, 0, b.bytes()); return; + } + case 2: { // Suspend + writeReply(id, 0, empty()); return; + } + case 3: { // Resume + if (device != null) try { device.resume(tid); } catch (IOException ignore) {} + writeReply(id, 0, empty()); return; + } + case 4: { // Status + // 1 = SLEEPING, 4 = RUNNING; suspendStatus bit 1 = SUSPENDED + Buf b = new Buf(); + b.writeInt(4); // RUNNING + b.writeInt(tid == lastSuspendedThread ? 1 : 0); + writeReply(id, 0, b.bytes()); return; + } + case 5: { // ThreadGroup + Buf b = new Buf(); b.writeLong(FAKE_GROUP_ID); writeReply(id, 0, b.bytes()); return; + } + case 6: { // Frames + int startFrame = readInt(p, 8); + int length = readInt(p, 12); + fetchStackForThread(tid); + Buf b = new Buf(); + int[] mids = lastStackMids; + int[] lines = lastStackLines; + int total = mids == null ? 0 : mids.length; + int count = total - startFrame; + if (length >= 0 && length < count) count = length; + if (count < 0) count = 0; + b.writeInt(count); + for (int i = 0; i < count; i++) { + int idx = startFrame + i; + long frameId = ((tid & 0xFFFFFFFFL) << 32) | (idx & 0xFFFFFFFFL); + b.writeLong(frameId); + // Location: typeTag, classID, methodID, codeIndex (in JDWP space) + SymbolTable.MethodInfo m = symbols.methodById(mids[idx]); + int classId = m != null ? m.classId : 0; + b.writeByte(TYPE_TAG_CLASS); + b.writeLong(toJdwpRef(classId)); + b.writeLong(toJdwpRef(mids[idx])); + b.writeLong(lines[idx]); + } + writeReply(id, 0, b.bytes()); return; + } + case 7: { // FrameCount + fetchStackForThread(tid); + Buf b = new Buf(); b.writeInt(lastStackMids == null ? 0 : lastStackMids.length); + writeReply(id, 0, b.bytes()); return; + } + case 12: { // SuspendCount + Buf b = new Buf(); + b.writeInt(tid == lastSuspendedThread ? 1 : 0); + writeReply(id, 0, b.bytes()); return; + } + default: + writeReply(id, 100, empty()); + } + } + + private void handleThreadGroup(int id, int cmd, byte[] p) throws IOException { + switch (cmd) { + case 1: { Buf b = new Buf(); b.writeString("main"); writeReply(id, 0, b.bytes()); return; } + case 2: { Buf b = new Buf(); b.writeLong(0); writeReply(id, 0, b.bytes()); return; } + case 3: { + Buf b = new Buf(); + b.writeInt(knownThreads.size()); + for (long t : knownThreads) b.writeLong(t); + b.writeInt(0); + writeReply(id, 0, b.bytes()); return; + } + default: + writeReply(id, 100, empty()); + } + } + + // -------- StackFrame ---------------------------------------------------- + + private void handleStackFrame(int id, int cmd, byte[] p) throws IOException { + long tid = readLong(p, 0); + long frameId = readLong(p, 8); + int frameIdx = (int)(frameId & 0xFFFFFFFFL); + switch (cmd) { + case 1: { // GetValues + int slotCount = readInt(p, 16); + // Read the requested slots into a list. + List requested = new ArrayList<>(); // each: { slot, sigByte } + int off = 20; + for (int i = 0; i < slotCount; i++) { + int slot = readInt(p, off); off += 4; + int sig = p[off] & 0xff; off += 1; + requested.add(new int[]{ slot, sig }); + } + // Synchronously ask the device for locals at frameIdx. + int[] devSlots; byte[] devTypes; long[] devValues; + synchronized (localsLock) { + pendingLocals = true; + lastLocalsSlots = null; lastLocalsTypes = null; lastLocalsValues = null; + try { device.getLocals(tid, frameIdx); } catch (IOException io) { + writeReply(id, 103, empty()); return; + } + long deadline = System.currentTimeMillis() + 2000; + while (pendingLocals && System.currentTimeMillis() < deadline) { + try { localsLock.wait(deadline - System.currentTimeMillis()); } catch (InterruptedException ie) { break; } + } + devSlots = lastLocalsSlots; devTypes = lastLocalsTypes; devValues = lastLocalsValues; + } + Buf b = new Buf(); + b.writeInt(slotCount); + for (int[] r : requested) { + int slot = r[0]; + int idx = findSlot(devSlots, slot); + if (idx < 0) { + b.writeByte('L'); b.writeLong(0); + } else { + byte tc = devTypes[idx]; + long v = devValues[idx]; + b.writeByte(tc); + switch ((char) tc) { + case 'Z': b.writeByte((int)v & 1); break; + case 'B': b.writeByte((int)v & 0xff); break; + case 'S': case 'C': b.writeShort((int)v & 0xffff); break; + case 'I': case 'F': b.writeInt((int)v); break; + case 'J': case 'D': b.writeLong(v); break; + case 'L': case '[': b.writeLong(v); break; + default: b.writeLong(v); + } + } + } + writeReply(id, 0, b.bytes()); + return; + } + case 3: { // ThisObject + Buf b = new Buf(); b.writeByte('L'); b.writeLong(0); writeReply(id, 0, b.bytes()); return; + } + default: + writeReply(id, 100, empty()); + } + } + + private int findSlot(int[] slots, int target) { + if (slots == null) return -1; + for (int i = 0; i < slots.length; i++) if (slots[i] == target) return i; + return -1; + } + + // -------- EventRequest -------------------------------------------------- + + private void handleEventRequest(int id, int cmd, byte[] p) throws IOException { + switch (cmd) { + case 1: { // Set + int eventKind = p[0] & 0xff; + int suspendPolicy = p[1] & 0xff; + int modCount = readInt(p, 2); + int off = 6; + long typeID = 0, methodID = 0, codeIndex = 0; + long stepThread = 0; + int stepDepth = 0; + for (int i = 0; i < modCount; i++) { + int modKind = p[off] & 0xff; off += 1; + switch (modKind) { + case 7: { // LocationOnly + int typeTag = p[off] & 0xff; off += 1; + typeID = readLong(p, off); off += 8; + methodID = readLong(p, off); off += 8; + codeIndex = readLong(p, off); off += 8; + break; + } + case 1: { // Count + off += 4; break; + } + case 2: { // ThreadOnly + off += 8; break; + } + case 3: case 4: case 5: { // ClassOnly, ClassMatch, ClassExclude + off = skipString(p, off + (modKind == 3 ? 8 : 0)); + break; + } + case 8: { // ExceptionOnly + off += 8 + 1 + 1; break; + } + case 10: { // Step modifier (threadID, size, depth) + stepThread = readLong(p, off); off += 8; + // size: 0=MIN(instruction), 1=LINE — we only do LINE + off += 4; + stepDepth = readInt(p, off); off += 4; + break; + } + case 11: { // InstanceOnly + off += 8; break; + } + default: + // Best-effort skip; many modifier kinds are variable-width + // but jdb mostly uses the ones above. + off = p.length; break; + } + } + int rid = nextRequestId.incrementAndGet(); + if (eventKind == EK_BREAKPOINT) { + // typeID / methodID arrive in JDWP-space; convert back. + int classIdInt = fromJdwpRef(typeID); + int methodIdInt = fromJdwpRef(methodID); + BpRequest br = new BpRequest(rid, classIdInt, methodIdInt, codeIndex, suspendPolicy); + bpRequests.put(rid, br); + // If the device isn't connected yet, the breakpoint will + // be replayed when onHello fires. Either way bpRequests + // is the source of truth so the event handler can match + // device-emitted BP_HIT events back to a JDWP request id. + if (device != null && deviceHelloReceived) try { + device.setBreakpoint(methodIdInt, (int) codeIndex); + } catch (IOException io) { /* ignore */ } + } else if (eventKind == EK_SINGLE_STEP && stepThread != 0) { + // depth: 0=INTO, 1=OVER, 2=OUT — same numeric values as + // the wire protocol's STEP_INTO/OVER/OUT, so no mapping. + stepRequests.put(stepThread, rid); + if (device != null && deviceHelloReceived) try { + device.step(stepThread, stepDepth); + } catch (IOException io) { /* ignore */ } + } + Buf b = new Buf(); b.writeInt(rid); writeReply(id, 0, b.bytes()); + return; + } + case 2: { // Clear + int eventKind = p[0] & 0xff; + int rid = readInt(p, 1); + BpRequest br = bpRequests.remove(rid); + if (br != null && device != null && eventKind == EK_BREAKPOINT) { + try { device.clearBreakpoint((int) br.methodID, (int) br.codeIndex); } catch (IOException ignore) {} + } + if (eventKind == EK_SINGLE_STEP) { + stepRequests.entrySet().removeIf(e -> e.getValue() == rid); + } + writeReply(id, 0, empty()); + return; + } + case 3: { // ClearAllBreakpoints + for (BpRequest br : bpRequests.values()) { + if (device != null) try { + device.clearBreakpoint((int) br.methodID, (int) br.codeIndex); + } catch (IOException ignore) {} + } + bpRequests.clear(); + writeReply(id, 0, empty()); + return; + } + default: + writeReply(id, 100, empty()); + } + } + + private void handleObject(int id, int cmd, byte[] p) throws IOException { + long objectId = readLong(p, 0); + switch (cmd) { + case 1: { // ReferenceType — get class + int classId = blockingGetObjectClass(objectId); + Buf b = new Buf(); + if (classId < 0) { + b.writeByte(TYPE_TAG_CLASS); + b.writeLong(0); + } else { + b.writeByte(TYPE_TAG_CLASS); + b.writeLong(toJdwpRef(classId)); + } + writeReply(id, 0, b.bytes()); + return; + } + case 2: { // GetValues — instance field reads. Stub: empty. + int count = readInt(p, 8); + Buf b = new Buf(); + b.writeInt(count); + for (int i = 0; i < count; i++) { + b.writeByte('L'); + b.writeLong(0); + } + writeReply(id, 0, b.bytes()); + return; + } + case 9: { // IsCollected — we don't track GC; always false. + Buf b = new Buf(); b.writeByte(0); writeReply(id, 0, b.bytes()); return; + } + default: + writeReply(id, 100, empty()); + } + } + + private void handleString(int id, int cmd, byte[] p) throws IOException { + long objectId = readLong(p, 0); + switch (cmd) { + case 1: { // Value + String s = blockingGetString(objectId); + Buf b = new Buf(); + b.writeString(s == null ? "" : s); + writeReply(id, 0, b.bytes()); + return; + } + default: + writeReply(id, 100, empty()); + } + } + + // -------- Synchronous request/reply against the device ---------------- + + private final Object objectClassLock = new Object(); + private boolean pendingObjectClass = false; + private int lastObjectClass = -1; + + private int blockingGetObjectClass(long objectId) { + if (device == null) return -1; + synchronized (objectClassLock) { + pendingObjectClass = true; + lastObjectClass = -1; + try { device.getObjectClass(objectId); } catch (IOException io) { return -1; } + long deadline = System.currentTimeMillis() + 2000; + while (pendingObjectClass && System.currentTimeMillis() < deadline) { + try { objectClassLock.wait(deadline - System.currentTimeMillis()); } + catch (InterruptedException ie) { break; } + } + return lastObjectClass; + } + } + + private final Object stringLock = new Object(); + private boolean pendingString = false; + private String lastString = null; + + private String blockingGetString(long objectId) { + if (device == null) return null; + synchronized (stringLock) { + pendingString = true; + lastString = null; + try { device.getString(objectId); } catch (IOException io) { return null; } + long deadline = System.currentTimeMillis() + 2000; + while (pendingString && System.currentTimeMillis() < deadline) { + try { stringLock.wait(deadline - System.currentTimeMillis()); } + catch (InterruptedException ie) { break; } + } + return lastString; + } + } + + // -------- DeviceListener (events from device) -------------------------- + + private final Object localsLock = new Object(); + private boolean pendingLocals = false; + private int[] lastLocalsSlots; + private byte[] lastLocalsTypes; + private long[] lastLocalsValues; + + private final Object stackLock = new Object(); + private volatile long stackThreadId = -1; + private volatile boolean pendingStack = false; + private volatile int[] lastStackMids; + private volatile int[] lastStackLines; + + private void fetchStackForThread(long tid) { + if (device == null) return; + synchronized (stackLock) { + // Already cached for this thread? Reuse without round-tripping. + if (stackThreadId == tid && lastStackMids != null && !pendingStack) { + return; + } + pendingStack = true; + stackThreadId = tid; + lastStackMids = null; + lastStackLines = null; + try { device.getStack(tid); } catch (IOException io) { pendingStack = false; return; } + long deadline = System.currentTimeMillis() + 2000; + while (pendingStack && System.currentTimeMillis() < deadline) { + try { stackLock.wait(deadline - System.currentTimeMillis()); } + catch (InterruptedException ie) { break; } + } + } + } + + @Override public void onHello(int version) { + System.out.println("[jdwp] device handshake (proto v" + version + ")"); + deviceHelloReceived = true; + if (out != null) { + sendVmStart(); + } + // Replay any breakpoints that were registered before the device + // joined — common when jdb's startup script issues "stop at" before + // the iOS app finishes booting. + if (device != null) { + for (BpRequest br : bpRequests.values()) { + try { device.setBreakpoint((int) br.methodID, (int) br.codeIndex); } + catch (IOException ignore) {} + } + } + } + + @Override public void onBreakpointHit(long threadId, int methodId, int line) { + System.out.println("[jdwp] BP_HIT tid=" + threadId + " methodId=" + methodId + " line=" + line); + if (!knownThreads.contains(threadId)) knownThreads.add(threadId); + lastSuspendedThread = threadId; + // Eagerly ask the device for stack so the IDE's subsequent Frames + // query returns something useful. + if (device != null) try { device.getStack(threadId); } catch (IOException ignore) {} + // Find matching JDWP request id; if none we still send a generic + // event so jdb sees the suspend. + int rid = 0; + for (BpRequest br : bpRequests.values()) { + if (br.methodID == methodId && br.codeIndex == line) { rid = br.requestId; break; } + } + try { + SymbolTable.MethodInfo m = symbols.methodById(methodId); + int classId = m != null ? m.classId : 0; + Buf b = new Buf(); + b.writeByte(SP_ALL); + b.writeInt(1); + b.writeByte(EK_BREAKPOINT); + b.writeInt(rid); + b.writeLong(threadId); + b.writeByte(TYPE_TAG_CLASS); + b.writeLong(toJdwpRef(classId)); + b.writeLong(toJdwpRef(methodId)); + b.writeLong(line); + writeEventCommand(b.bytes()); + } catch (IOException e) { + System.err.println("[jdwp] failed to send breakpoint event: " + e.getMessage()); + } + } + + @Override public void onStepComplete(long threadId, int methodId, int line) { + Integer rid = stepRequests.remove(threadId); + try { + SymbolTable.MethodInfo m = symbols.methodById(methodId); + int classId = m != null ? m.classId : 0; + Buf b = new Buf(); + b.writeByte(SP_ALL); + b.writeInt(1); + b.writeByte(EK_SINGLE_STEP); + b.writeInt(rid == null ? 0 : rid); + b.writeLong(threadId); + b.writeByte(TYPE_TAG_CLASS); + b.writeLong(toJdwpRef(classId)); + b.writeLong(toJdwpRef(methodId)); + b.writeLong(line); + writeEventCommand(b.bytes()); + } catch (IOException e) { + System.err.println("[jdwp] failed to send step event: " + e.getMessage()); + } + } + + @Override public void onStack(long threadId, int[] methodIds, int[] lines) { + synchronized (stackLock) { + lastStackMids = methodIds; + lastStackLines = lines; + stackThreadId = threadId; + pendingStack = false; + stackLock.notifyAll(); + } + } + + @Override public void onLocals(int[] slots, byte[] typeCodes, long[] values) { + synchronized (localsLock) { + lastLocalsSlots = slots; + lastLocalsTypes = typeCodes; + lastLocalsValues = values; + pendingLocals = false; + localsLock.notifyAll(); + } + } + + @Override public void onVmDeath() { + try { + Buf b = new Buf(); + b.writeByte(SP_NONE); + b.writeInt(1); + b.writeByte(EK_VM_DEATH); + b.writeInt(0); + writeEventCommand(b.bytes()); + } catch (IOException ignore) {} + } + + @Override public void onStringValue(String value) { + synchronized (stringLock) { + lastString = value; + pendingString = false; + stringLock.notifyAll(); + } + } + @Override public void onObjectClass(int classId) { + synchronized (objectClassLock) { + lastObjectClass = classId; + pendingObjectClass = false; + objectClassLock.notifyAll(); + } + } + @Override public void onReplyStatus() {} + @Override public void onUnknownEvent(int code, byte[] payload) { + System.out.println("[jdwp] unknown device event code 0x" + Integer.toHexString(code)); + } + @Override public void onDisconnected() { + System.out.println("[jdwp] device disconnected"); + onVmDeath(); + } + + // -------- buffer helpers ----------------------------------------------- + + private static int readInt(byte[] b, int off) { + return ((b[off] & 0xff) << 24) | ((b[off+1] & 0xff) << 16) + | ((b[off+2] & 0xff) << 8) | (b[off+3] & 0xff); + } + private static long readLong(byte[] b, int off) { + return ((long)readInt(b, off) << 32) | (readInt(b, off + 4) & 0xffffffffL); + } + private static String readString(byte[] b, int off) { + int len = readInt(b, off); + return new String(b, off + 4, len, StandardCharsets.UTF_8); + } + private static int skipString(byte[] b, int off) { + int len = readInt(b, off); + return off + 4 + len; + } + + /** Tiny growable byte buffer with JDWP-flavoured writers. */ + private static final class Buf { + private byte[] arr = new byte[64]; + private int n = 0; + private void ensure(int more) { + if (n + more > arr.length) { + int cap = arr.length; + while (cap < n + more) cap *= 2; + byte[] na = new byte[cap]; + System.arraycopy(arr, 0, na, 0, n); + arr = na; + } + } + void writeByte(int v) { ensure(1); arr[n++] = (byte)v; } + void writeShort(int v) { ensure(2); arr[n++] = (byte)(v>>>8); arr[n++] = (byte)v; } + void writeInt(int v) { ensure(4); arr[n++]=(byte)(v>>>24); arr[n++]=(byte)(v>>>16); arr[n++]=(byte)(v>>>8); arr[n++]=(byte)v; } + void writeLong(long v) { writeInt((int)(v>>>32)); writeInt((int)v); } + void writeString(String s) { + byte[] u = s.getBytes(StandardCharsets.UTF_8); + writeInt(u.length); + ensure(u.length); + System.arraycopy(u, 0, arr, n, u.length); + n += u.length; + } + byte[] bytes() { byte[] o = new byte[n]; System.arraycopy(arr, 0, o, 0, n); return o; } + } +} diff --git a/maven/cn1-debug-proxy/src/main/java/com/codename1/debug/proxy/LoggingListener.java b/maven/cn1-debug-proxy/src/main/java/com/codename1/debug/proxy/LoggingListener.java new file mode 100644 index 0000000000..64a3add69d --- /dev/null +++ b/maven/cn1-debug-proxy/src/main/java/com/codename1/debug/proxy/LoggingListener.java @@ -0,0 +1,83 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + */ +package com.codename1.debug.proxy; + +import java.util.Arrays; + +/** + * Diagnostic implementation of {@link DeviceConnection.DeviceListener} that + * just prints every event. Useful when iterating on the wire protocol + * before the JDWP front-end is wired in: lets you confirm the device side + * is sending well-formed frames by hand-poking it from the proxy CLI. + */ +public final class LoggingListener implements DeviceConnection.DeviceListener { + + private final SymbolTable symbols; + + public LoggingListener(SymbolTable symbols) { + this.symbols = symbols; + } + + @Override public void onHello(int version) { + System.out.println("[event] HELLO proto-version=" + version); + } + + @Override public void onBreakpointHit(long threadId, int methodId, int line) { + System.out.println("[event] BP_HIT tid=" + threadId + " " + describeLocation(methodId, line)); + } + + @Override public void onStepComplete(long threadId, int methodId, int line) { + System.out.println("[event] STEP_COMPLETE tid=" + threadId + " " + describeLocation(methodId, line)); + } + + @Override public void onStack(long threadId, int[] methodIds, int[] lines) { + System.out.println("[event] STACK tid=" + threadId + " depth=" + methodIds.length); + for (int i = 0; i < methodIds.length; i++) { + System.out.println(" #" + i + " " + describeLocation(methodIds[i], lines[i])); + } + } + + @Override public void onLocals(int[] slots, byte[] typeCodes, long[] values) { + System.out.println("[event] LOCALS count=" + slots.length); + for (int i = 0; i < slots.length; i++) { + System.out.println(" slot=" + slots[i] + " type='" + (char) typeCodes[i] + + "' value=" + formatValue(typeCodes[i], values[i])); + } + } + + @Override public void onVmDeath() { System.out.println("[event] VM_DEATH"); } + @Override public void onStringValue(String value) { System.out.println("[event] STRING_VALUE=" + value); } + @Override public void onObjectClass(int classId) { System.out.println("[event] OBJECT_CLASS=" + classId); } + @Override public void onReplyStatus() { System.out.println("[event] REPLY_STATUS"); } + @Override public void onUnknownEvent(int code, byte[] payload) { + System.out.println("[event] UNKNOWN code=0x" + Integer.toHexString(code) + " payload=" + Arrays.toString(payload)); + } + @Override public void onDisconnected() { System.out.println("[event] DISCONNECTED"); } + + private String describeLocation(int methodId, int line) { + SymbolTable.MethodInfo m = symbols.methodById(methodId); + if (m == null) return "method=" + methodId + " line=" + line; + SymbolTable.ClassInfo c = symbols.classById(m.classId); + String cls = c != null ? c.name.replace('_', '.') : "?"; + return cls + "." + m.name + m.descriptor + ":" + line; + } + + private String formatValue(byte typeCode, long value) { + switch ((char) typeCode) { + case 'I': case 'B': case 'S': case 'C': case 'Z': return Integer.toString((int) value); + case 'J': return Long.toString(value); + case 'F': return Float.toString(Float.intBitsToFloat((int) value)); + case 'D': return Double.toString(Double.longBitsToDouble(value)); + case 'L': case '[': + return value == 0 ? "null" : "ref@0x" + Long.toHexString(value); + default: return "0x" + Long.toHexString(value); + } + } +} diff --git a/maven/cn1-debug-proxy/src/main/java/com/codename1/debug/proxy/ProxyMain.java b/maven/cn1-debug-proxy/src/main/java/com/codename1/debug/proxy/ProxyMain.java new file mode 100644 index 0000000000..cf85db4747 --- /dev/null +++ b/maven/cn1-debug-proxy/src/main/java/com/codename1/debug/proxy/ProxyMain.java @@ -0,0 +1,96 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + */ +package com.codename1.debug.proxy; + +import java.io.IOException; +import java.nio.file.Path; +import java.nio.file.Paths; + +/** + * CLI entry point for the on-device-debug proxy. + * + * Usage: {@code java -jar cn1-debug-proxy.jar --symbols=path/to/cn1-symbols.txt + * --device-port=55333 --jdwp-port=8000 [--no-jdwp]} + * + * - --symbols : path to the sidecar emitted by the translator. Required. + * - --device-port : TCP port to listen on for the device. Default 55333. + * - --jdwp-port : TCP port to listen on for jdb / IDE. Default 8000. + * - --no-jdwp : skip the JDWP front-end; events are dumped to stdout. + * Useful for shaking out the wire protocol before the + * JDWP translation layer is ready. + * + * The proxy waits for both sides to connect, then forwards events from the + * device to the JDWP listener (which translates them into JDWP events) and + * commands from JDWP back to the device. + */ +public final class ProxyMain { + + public static void main(String[] args) throws Exception { + String symbolsPath = null; + int devicePort = 55333; + int jdwpPort = 8000; + boolean noJdwp = false; + + for (String a : args) { + if (a.startsWith("--symbols=")) symbolsPath = a.substring("--symbols=".length()); + else if (a.startsWith("--device-port=")) devicePort = Integer.parseInt(a.substring("--device-port=".length())); + else if (a.startsWith("--jdwp-port=")) jdwpPort = Integer.parseInt(a.substring("--jdwp-port=".length())); + else if (a.equals("--no-jdwp")) noJdwp = true; + else if (a.equals("--help") || a.equals("-h")) { printUsage(); return; } + else { + System.err.println("Unrecognised argument: " + a); + printUsage(); + System.exit(2); + } + } + if (symbolsPath == null) { + System.err.println("--symbols= is required"); + printUsage(); + System.exit(2); + } + + SymbolTable symbols = SymbolTable.load(Paths.get(symbolsPath)); + System.out.println("Loaded " + symbols.allClasses().size() + " classes, " + + symbols.allMethods().size() + " methods from " + symbolsPath); + + final DeviceConnection.DeviceListener listener; + final JdwpServer jdwpServer; + if (noJdwp) { + listener = new LoggingListener(symbols); + jdwpServer = null; + } else { + jdwpServer = new JdwpServer(jdwpPort, symbols); + listener = jdwpServer; + final JdwpServer js = jdwpServer; + Thread t = new Thread(() -> { + try { + js.acceptAndServe(); + } catch (Throwable e) { + System.err.println("[jdwp] " + e); + e.printStackTrace(); + } + }, "cn1-debug-jdwp"); + t.setDaemon(true); + t.start(); + } + + DeviceConnection dev = new DeviceConnection(devicePort, listener); + if (jdwpServer != null) jdwpServer.setDevice(dev); + try { + dev.acceptAndServe(); + } catch (IOException e) { + System.err.println("[device] " + e.getMessage()); + } + } + + private static void printUsage() { + System.err.println("Usage: cn1-debug-proxy --symbols= [--device-port=

] [--jdwp-port=

] [--no-jdwp]"); + } +} diff --git a/maven/cn1-debug-proxy/src/main/java/com/codename1/debug/proxy/SymbolTable.java b/maven/cn1-debug-proxy/src/main/java/com/codename1/debug/proxy/SymbolTable.java new file mode 100644 index 0000000000..634da02ac7 --- /dev/null +++ b/maven/cn1-debug-proxy/src/main/java/com/codename1/debug/proxy/SymbolTable.java @@ -0,0 +1,191 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + */ +package com.codename1.debug.proxy; + +import java.io.BufferedReader; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.TreeSet; + +/** + * In-memory model of the cn1-symbols.txt sidecar emitted by the translator + * when {@code -Dcn1.onDeviceDebug=true} is set. Resolves device-sent + * integer IDs back to JVM-style class/method names so the proxy can answer + * JDWP queries like AllClasses, ClassesBySignature, Method.LineTable, etc. + * + * The format intentionally trades cleverness for parseability — see + * Parser.writeSymbolSidecar in the translator for the writer. + */ +public final class SymbolTable { + + public static final class ClassInfo { + public final int classId; + public final String name; // e.g. "java_lang_String" (translator convention) + public final String sourceFile; + public final List methods = new ArrayList<>(); + + ClassInfo(int classId, String name, String sourceFile) { + this.classId = classId; + this.name = name; + this.sourceFile = sourceFile; + } + + /** JVM-style signature: "Ljava/lang/String;" derived from the underscore-separated name. */ + public String jvmSignature() { + return "L" + name.replace('_', '/') + ";"; + } + } + + public static final class LocalVarInfo { + public final int slot; + public final String name; + public final String descriptor; + + LocalVarInfo(int slot, String name, String descriptor) { + this.slot = slot; + this.name = name; + this.descriptor = descriptor; + } + } + + public static final class MethodInfo { + public final int methodId; + public final int classId; + public final String name; + public final String descriptor; + public final TreeSet lines = new TreeSet<>(); + public final List locals = new ArrayList<>(); + + MethodInfo(int methodId, int classId, String name, String descriptor) { + this.methodId = methodId; + this.classId = classId; + this.name = name; + this.descriptor = descriptor; + } + + /** + * argSlots counts JVM local slots consumed by method args (used by + * JDWP VariableTable's argCnt field). Instance methods get an extra + * slot for {@code this}; longs/doubles take two slots each. + */ + public int argSlots(boolean isStatic) { + int n = isStatic ? 0 : 1; + int i = descriptor.indexOf('('); + int end = descriptor.indexOf(')'); + if (i < 0 || end < 0) return n; + int j = i + 1; + while (j < end) { + char c = descriptor.charAt(j); + switch (c) { + case 'J': case 'D': n += 2; j++; break; + case 'L': + j = descriptor.indexOf(';', j) + 1; n++; break; + case '[': + while (descriptor.charAt(j) == '[') j++; + if (descriptor.charAt(j) == 'L') j = descriptor.indexOf(';', j) + 1; + else j++; + n++; break; + default: n++; j++; + } + } + return n; + } + } + + private final Map classesById = new HashMap<>(); + private final Map methodsById = new HashMap<>(); + private final Map classesByJvmSig = new HashMap<>(); + private final Map classesBySourceFile = new HashMap<>(); + + public static SymbolTable load(Path file) throws IOException { + SymbolTable t = new SymbolTable(); + try (BufferedReader r = Files.newBufferedReader(file, StandardCharsets.UTF_8)) { + String line; + while ((line = r.readLine()) != null) { + if (line.isEmpty() || line.startsWith("#")) continue; + String[] parts = line.split("\t", -1); + switch (parts[0]) { + case "version": + // 1 is the only known version; future versions can branch here. + break; + case "class": { + if (parts.length < 4) continue; + int id = Integer.parseInt(parts[1]); + ClassInfo c = new ClassInfo(id, parts[2], parts[3]); + t.classesById.put(id, c); + t.classesByJvmSig.put(c.jvmSignature(), c); + if (!parts[3].isEmpty()) { + t.classesBySourceFile.put(parts[3], c); + } + break; + } + case "method": { + if (parts.length < 5) continue; + int mid = Integer.parseInt(parts[1]); + int cid = Integer.parseInt(parts[2]); + MethodInfo m = new MethodInfo(mid, cid, parts[3], parts[4]); + t.methodsById.put(mid, m); + ClassInfo c = t.classesById.get(cid); + if (c != null) c.methods.add(m); + break; + } + case "line": { + if (parts.length < 3) continue; + int mid = Integer.parseInt(parts[1]); + int ln = Integer.parseInt(parts[2]); + MethodInfo m = t.methodsById.get(mid); + if (m != null) m.lines.add(ln); + break; + } + case "var": { + if (parts.length < 5) continue; + int mid = Integer.parseInt(parts[1]); + int slot = Integer.parseInt(parts[2]); + MethodInfo m = t.methodsById.get(mid); + if (m != null) m.locals.add(new LocalVarInfo(slot, parts[3], parts[4])); + break; + } + default: + // unknown directives are ignored to allow forward-compat + } + } + } + return t; + } + + public ClassInfo classById(int id) { return classesById.get(id); } + public MethodInfo methodById(int id) { return methodsById.get(id); } + public ClassInfo classByJvmSignature(String sig) { return classesByJvmSig.get(sig); } + public ClassInfo classBySourceFile(String name) { return classesBySourceFile.get(name); } + public java.util.Collection allClasses() { return classesById.values(); } + public java.util.Collection allMethods() { return methodsById.values(); } + + /** + * Resolves "ClassName:42" style breakpoint references the way jdb states + * them. Walks the class's methods and returns the one whose line set + * contains the requested source line, or null if no method covers it. + */ + public MethodInfo methodCoveringLine(String className, int line) { + ClassInfo c = classesById.values().stream() + .filter(ci -> ci.name.equals(className) || ci.name.replace('_', '.').equals(className)) + .findFirst().orElse(null); + if (c == null) return null; + for (MethodInfo m : c.methods) { + if (m.lines.contains(line)) return m; + } + return null; + } +} diff --git a/maven/cn1-debug-proxy/src/main/java/com/codename1/debug/proxy/WireProtocol.java b/maven/cn1-debug-proxy/src/main/java/com/codename1/debug/proxy/WireProtocol.java new file mode 100644 index 0000000000..608e668116 --- /dev/null +++ b/maven/cn1-debug-proxy/src/main/java/com/codename1/debug/proxy/WireProtocol.java @@ -0,0 +1,52 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + */ +package com.codename1.debug.proxy; + +/** + * Wire-protocol constants shared with the device-side runtime in + * Ports/iOSPort/nativeSources/cn1_debugger.m. The two ends must stay in + * lock-step. + * + * Frame format: 4-byte big-endian payload length, 1-byte command/event + * code, then payload bytes. Codes 0x01-0x7F are commands from the proxy + * to the device; codes 0x80-0xFF are events the device emits. + */ +public final class WireProtocol { + private WireProtocol() {} + + public static final int PROTOCOL_VERSION = 1; + + public static final int CMD_SET_BREAKPOINT = 0x02; + public static final int CMD_CLEAR_BREAKPOINT = 0x03; + public static final int CMD_RESUME = 0x04; + public static final int CMD_SUSPEND = 0x05; + public static final int CMD_GET_THREADS = 0x06; + public static final int CMD_GET_STACK = 0x07; + public static final int CMD_GET_LOCALS = 0x08; + public static final int CMD_STEP = 0x09; + public static final int CMD_DISPOSE = 0x0A; + public static final int CMD_GET_STRING = 0x0B; + public static final int CMD_GET_OBJECT_CLASS = 0x0C; + + public static final int EVT_HELLO = 0x80; + public static final int EVT_THREAD_LIST = 0x81; + public static final int EVT_BP_HIT = 0x82; + public static final int EVT_STEP_COMPLETE = 0x83; + public static final int EVT_STACK = 0x84; + public static final int EVT_LOCALS = 0x85; + public static final int EVT_VM_DEATH = 0x86; + public static final int EVT_STRING_VALUE = 0x87; + public static final int EVT_REPLY_STATUS = 0x88; + public static final int EVT_OBJECT_CLASS = 0x89; + + public static final int STEP_INTO = 0; + public static final int STEP_OVER = 1; + public static final int STEP_OUT = 2; +} diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java index b48bd94c96..3cb42937b8 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java @@ -1676,6 +1676,11 @@ public void usesClassMethod(String cls, String method) { // includeNullChecks enables null checks on everything else (methods, arrays, etc..) String includeNullChecks = Boolean.valueOf(request.getArg("ios.includeNullChecks", "true")) ? "true":"false"; String bundleVersionNumber = request.getArg("ios.bundleVersion", buildVersion); + // On-device-debug toggle: tells the translator to emit per-frame + // locals-address tables, the cn1_frame_info side-tables, and to + // flip the CN1_ON_DEVICE_DEBUG #define in cn1_globals.h so the + // generated Xcode build links the listener thread. + String onDeviceDebug = Boolean.valueOf(request.getArg("ios.onDeviceDebug", "false")) ? "true" : "false"; if (enableGalleryMultiselect && photoLibraryUsage) { @@ -1690,7 +1695,7 @@ public void usesClassMethod(String cls, String method) { debug("Building using addLibs="+addLibs); stopwatch.split("Prepare ParparVM"); try { - if (!exec(userDir, env, 420000, "java", "-DsaveUnitTests=" + isUnitTestMode(), "-DfieldNullChecks=" + fieldNullChecks, "-DINCLUDE_NPE_CHECKS=" + includeNullChecks, "-DbundleVersionNumber=" + bundleVersionNumber, "-Xmx384m", + if (!exec(userDir, env, 420000, "java", "-DsaveUnitTests=" + isUnitTestMode(), "-DfieldNullChecks=" + fieldNullChecks, "-DINCLUDE_NPE_CHECKS=" + includeNullChecks, "-Dcn1.onDeviceDebug=" + onDeviceDebug, "-DbundleVersionNumber=" + bundleVersionNumber, "-Xmx384m", "-jar", parparVMCompilerJar, "ios", classesDir.getAbsolutePath() + ";" + resDir.getAbsolutePath() + ";" + buildinRes.getAbsolutePath(), @@ -2758,6 +2763,27 @@ public boolean accept(File file, String string) { // nothing to inject here? move along String inject = request.getArg("ios.plistInject", "CFBundleShortVersionString " + buildVersion +""); + + // On-device-debug: drop the proxy host/port into Info.plist so + // cn1_debugger.m can read them at app boot without needing the + // build to also patch source files. + if ("true".equalsIgnoreCase(request.getArg("ios.onDeviceDebug", "false"))) { + String proxyHost = request.getArg("ios.onDeviceDebug.proxyHost", "127.0.0.1"); + String proxyPort = request.getArg("ios.onDeviceDebug.proxyPort", "55333"); + String waitForAttach = "true".equalsIgnoreCase( + request.getArg("ios.onDeviceDebug.waitForAttach", "false")) ? "true" : "false"; + inject += "\nCN1ProxyHost\n" + proxyHost + ""; + inject += "\nCN1ProxyPort\n" + proxyPort + ""; + inject += "\nCN1ProxyWaitForAttach<" + waitForAttach + "/>"; + // ATS exemption for localhost / arbitrary loads so the device + // can dial out to the developer's laptop without a TLS chain. + if (!inject.contains("NSAppTransportSecurity")) { + inject += "\nNSAppTransportSecurity" + + "" + + "NSAllowsArbitraryLoads" + + ""; + } + } String applicationQueriesSchemes = request.getArg("ios.applicationQueriesSchemes", null); if(applicationQueriesSchemes != null && applicationQueriesSchemes.length() > 0) { diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/IosOnDeviceDebuggingMojo.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/IosOnDeviceDebuggingMojo.java new file mode 100644 index 0000000000..f49f44b3f2 --- /dev/null +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/IosOnDeviceDebuggingMojo.java @@ -0,0 +1,160 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + */ +package com.codename1.maven; + +import org.apache.maven.plugin.MojoExecutionException; +import org.apache.maven.plugin.MojoFailureException; +import org.apache.maven.plugins.annotations.Mojo; +import org.apache.maven.plugins.annotations.Parameter; + +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import java.util.stream.Stream; + +/** + * Launches the desktop on-device-debug proxy and prints instructions for + * attaching jdb. + * + * Run this AFTER {@code mvn cn1:buildIosXcodeProject -Dcodename1.arg.ios.onDeviceDebug=true}, + * which generates an Xcode project pre-flipped with CN1_ON_DEVICE_DEBUG and + * the proxy host/port in Info.plist. Open that project in Xcode and run it + * on the simulator (or a tethered device on the same WiFi). The device-side + * cn1_debugger thread will dial out to localhost:55333; attach jdb to + * localhost:8000. + * + * The mojo blocks until the proxy exits (typically when the user disposes + * jdb). + * + * Properties: + * -Dcn1.onDeviceDebug.symbolsFile=path override sidecar location (else autodetect) + * -Dcn1.onDeviceDebug.devicePort=55333 + * -Dcn1.onDeviceDebug.jdwpPort=8000 + * -Dcn1.onDeviceDebug.proxyJar=path override proxy jar location + */ +@Mojo(name="ios-on-device-debugging") +public class IosOnDeviceDebuggingMojo extends AbstractCN1Mojo { + + @Parameter(property = "cn1.onDeviceDebug.symbolsFile") + private String symbolsFile; + + @Parameter(property = "cn1.onDeviceDebug.devicePort", defaultValue = "55333") + private int devicePort; + + @Parameter(property = "cn1.onDeviceDebug.jdwpPort", defaultValue = "8000") + private int jdwpPort; + + @Parameter(property = "cn1.onDeviceDebug.proxyJar") + private String proxyJar; + + @Override + protected void executeImpl() throws MojoExecutionException, MojoFailureException { + File commonDir = getCN1ProjectDir(); + if (commonDir == null) { + throw new MojoFailureException("Could not locate Codename One project root"); + } + File rootMavenProjectDir = commonDir.getParentFile(); + + File symbols = resolveSymbols(rootMavenProjectDir); + File jar = resolveProxyJar(); + + getLog().info("On-device-debug proxy starting:"); + getLog().info(" symbols : " + symbols); + getLog().info(" device : listening on tcp://0.0.0.0:" + devicePort); + getLog().info(" jdwp : listening on tcp://0.0.0.0:" + jdwpPort); + getLog().info(""); + getLog().info("Next steps:"); + getLog().info(" 1. Open the generated Xcode project in iOS Simulator or on device"); + getLog().info(" (must be on the same network for a physical device)."); + getLog().info(" 2. Once the app boots it will dial in and the proxy will log a HELLO."); + getLog().info(" 3. Attach a debugger: jdb -attach localhost:" + jdwpPort); + getLog().info(""); + + ProcessBuilder pb = new ProcessBuilder( + javaBinary(), + "-jar", jar.getAbsolutePath(), + "--symbols=" + symbols.getAbsolutePath(), + "--device-port=" + devicePort, + "--jdwp-port=" + jdwpPort); + pb.inheritIO(); + try { + Process proc = pb.start(); + int rc = proc.waitFor(); + if (rc != 0) { + getLog().warn("Proxy exited with code " + rc); + } + } catch (IOException | InterruptedException e) { + throw new MojoExecutionException("Failed to run proxy: " + e.getMessage(), e); + } + } + + private File resolveSymbols(File rootMavenProjectDir) throws MojoFailureException { + if (symbolsFile != null && !symbolsFile.isEmpty()) { + File f = new File(symbolsFile); + if (!f.isFile()) throw new MojoFailureException("Configured symbols file does not exist: " + f); + return f; + } + // Autodetect: walk common/target/codenameone for cn1-symbols.txt. + File common = new File(rootMavenProjectDir, "common"); + if (!common.isDirectory()) common = rootMavenProjectDir; + File target = new File(common, "target"); + if (!target.isDirectory()) { + throw new MojoFailureException("No target/ found under " + common + + ". Did you run 'mvn cn1:buildIosXcodeProject -Dcodename1.arg.ios.onDeviceDebug=true' first?"); + } + try (Stream walk = Files.walk(target.toPath())) { + List hits = new ArrayList<>(); + walk.filter(p -> p.getFileName().toString().equals("cn1-symbols.txt")).forEach(hits::add); + if (hits.isEmpty()) { + throw new MojoFailureException("No cn1-symbols.txt found under " + target + + ". The translator only emits it when -Dcodename1.arg.ios.onDeviceDebug=true is set."); + } + // Pick the most recently modified. + hits.sort(Comparator.comparingLong((Path p) -> p.toFile().lastModified()).reversed()); + return hits.get(0).toFile(); + } catch (IOException e) { + throw new MojoFailureException("Failed to scan for cn1-symbols.txt: " + e.getMessage(), e); + } + } + + private File resolveProxyJar() throws MojoFailureException { + if (proxyJar != null && !proxyJar.isEmpty()) { + File f = new File(proxyJar); + if (!f.isFile()) throw new MojoFailureException("Configured proxy jar does not exist: " + f); + return f; + } + // Try the locally-built standalone shaded jar (developer workflow). + String userHome = System.getProperty("user.home", ""); + File m2 = new File(userHome, ".m2/repository/com/codenameone/cn1-debug-proxy"); + if (m2.isDirectory()) { + File[] versions = m2.listFiles(File::isDirectory); + if (versions != null) { + for (File v : versions) { + File jar = new File(v, "cn1-debug-proxy-" + v.getName() + "-standalone.jar"); + if (jar.isFile()) return jar; + } + } + } + throw new MojoFailureException( + "Could not locate cn1-debug-proxy standalone jar in ~/.m2. " + + "Build it once with 'cd maven/cn1-debug-proxy && mvn install', " + + "or pass -Dcn1.onDeviceDebug.proxyJar=."); + } + + private String javaBinary() { + String home = System.getProperty("java.home"); + File bin = new File(home, "bin/java"); + return bin.isFile() ? bin.getAbsolutePath() : "java"; + } +} diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/buildWrappers/BuildIosOnDeviceDebugMojo.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/buildWrappers/BuildIosOnDeviceDebugMojo.java new file mode 100644 index 0000000000..b3ac81b15d --- /dev/null +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/buildWrappers/BuildIosOnDeviceDebugMojo.java @@ -0,0 +1,74 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + */ +package com.codename1.maven.buildWrappers; + +import org.apache.maven.plugin.AbstractMojo; +import org.apache.maven.plugin.MojoExecutionException; +import org.apache.maven.plugin.MojoFailureException; +import org.apache.maven.plugins.annotations.Mojo; +import org.apache.maven.plugins.annotations.Parameter; +import org.apache.maven.plugins.annotations.ResolutionScope; +import org.apache.maven.project.MavenProject; +import org.apache.maven.shared.invoker.*; + +import java.io.File; +import java.util.Collections; +import java.util.Properties; + +/** + * Builds an iOS app instrumented for on-device debugging and submits it to + * the cloud build server. The user pairs this with {@code mvn + * cn1:ios-on-device-debugging} (which launches the desktop proxy) and then + * attaches jdb / IntelliJ / VS Code over JDWP — see + * {@code docs/developer-guide/On-Device-Debugging.adoc}. + * + * Forces {@code codename1.arg.ios.onDeviceDebug=true} so the listener + * thread is linked into the binary and the Info.plist gets the proxy + * connection settings, regardless of what the project's + * codenameone_settings.properties contains. + */ +@Mojo(name = "buildIosOnDeviceDebug", + requiresDependencyResolution = ResolutionScope.NONE, + requiresDependencyCollection = ResolutionScope.NONE) +public class BuildIosOnDeviceDebugMojo extends AbstractMojo { + + @Parameter(defaultValue = "${project}", readonly = true) + private MavenProject project; + + @Override + public void execute() throws MojoExecutionException, MojoFailureException { + if (!project.isExecutionRoot()) { + getLog().info("Skipping execution for non-root project"); + return; + } + InvocationRequest request = new DefaultInvocationRequest(); + request.setPomFile(new File("pom.xml")); + request.setGoals(Collections.singletonList("package")); + + Properties properties = new Properties(); + properties.setProperty("skipTests", "true"); + properties.setProperty("codename1.platform", "ios"); + properties.setProperty("codename1.buildTarget", "ios-on-device-debug"); + // Force-on regardless of codenameone_settings.properties so this + // goal is self-contained from the IDE menu. + properties.setProperty("codename1.arg.ios.onDeviceDebug", "true"); + request.setProperties(properties); + + Invoker invoker = new DefaultInvoker(); + try { + InvocationResult result = invoker.execute(request); + if (result.getExitCode() != 0) { + throw new MojoFailureException("Failed to build project with exit code " + result.getExitCode()); + } + } catch (MavenInvocationException e) { + throw new MojoExecutionException("Failed to invoke Maven", e); + } + } +} diff --git a/maven/codenameone-maven-plugin/src/main/resources/com/codename1/maven/buildxml-template.xml b/maven/codenameone-maven-plugin/src/main/resources/com/codename1/maven/buildxml-template.xml index 1210f12229..273421c8da 100644 --- a/maven/codenameone-maven-plugin/src/main/resources/com/codename1/maven/buildxml-template.xml +++ b/maven/codenameone-maven-plugin/src/main/resources/com/codename1/maven/buildxml-template.xml @@ -60,6 +60,38 @@ This the Ant build script used by the CN1BuildMojo for sending to the build serv + + + + + + + + diff --git a/maven/pom.xml b/maven/pom.xml index 6b6f9e7b2d..0dca49fedd 100644 --- a/maven/pom.xml +++ b/maven/pom.xml @@ -71,6 +71,7 @@ codenameone-maven-plugin cn1app-archetype cn1lib-archetype + cn1-debug-proxy diff --git a/vm/ByteCodeTranslator/src/cn1_globals.h b/vm/ByteCodeTranslator/src/cn1_globals.h index 8f92825bb8..f4992b4f4f 100644 --- a/vm/ByteCodeTranslator/src/cn1_globals.h +++ b/vm/ByteCodeTranslator/src/cn1_globals.h @@ -20,6 +20,13 @@ //#define CN1_INCLUDE_NPE_CHECKS #define CN1_INCLUDE_ARRAY_BOUND_CHECKS +// Uncommented by the translator (driven by the cn1.onDeviceDebug system +// property) when an on-device-debug build is requested. Enables per-frame +// locals-address tables, the cn1DebuggerActive hot-path check inside +// __CN1_DEBUG_INFO, and the proxy listener thread. Release builds leave +// this off and pay no overhead. +//#define CN1_ON_DEVICE_DEBUG + #ifdef DEBUG_GC_ALLOCATIONS #define DEBUG_GC_VARIABLES int line; int className; #define DEBUG_GC_INIT 0, 0, @@ -790,7 +797,20 @@ struct ThreadLocalData { int* callStackLine; int* callStackMethod; int callStackOffset; - + +#ifdef CN1_ON_DEVICE_DEBUG + // Per-frame pointer to a stack-allocated array of void* addresses, one per + // JVM local slot in the current method. Populated by translator-emitted + // prologue code in debug builds; consulted by the debugger thread to read + // primitive locals (the auto C variables are volatile so their address is + // stable for the duration of the frame). + void*** callStackLocalsAddresses; + // Per-frame pointer to the static cn1_frame_info struct for the current + // method. Carries the variable side-table the debugger uses to map source + // lines to slot/type info. + const struct cn1_frame_info** callStackFrameInfo; +#endif + char* utf8Buffer; int utf8BufferSize; JAVA_BOOLEAN threadKilled; // we don't expect to see this in the GC @@ -799,7 +819,48 @@ struct ThreadLocalData { //#define BLOCK_FOR_GC() while(threadStateData->threadBlockedByGC) { usleep(500); } +#ifdef CN1_ON_DEVICE_DEBUG +// One row of the variable side-table: a single (line, slot, typeCode) tuple. +// typeCode is the JVM type descriptor first char (I/J/F/D/Z/B/S/C/L/[) so the +// debugger thread knows how to dereference the void* held in +// callStackLocalsAddresses[offset][slot]. +struct cn1_var_entry { + int line; + int slot; + char typeCode; +}; + +// Per-method static metadata emitted once per translated method. Held alive +// for the life of the program. The translator emits an instance as +// "static const struct cn1_frame_info __cn1_finfo_ = { ... };" and +// passes &__cn1_finfo_ into the frame at method entry. +struct cn1_frame_info { + int classId; + int methodId; + int numLocals; + int varTableCount; + const struct cn1_var_entry* varTable; +}; + +// Set to non-zero by the debugger proxy listener once a proxy has connected +// and is ready to receive events. Read on the hot path of __CN1_DEBUG_INFO, +// so kept as a plain volatile int (predictable branch when zero). +extern volatile int cn1DebuggerActive; + +// Cold-path callee invoked by __CN1_DEBUG_INFO when cn1DebuggerActive is set. +// Defined in cn1_debugger.m (iOS port) / a no-op shim in release builds. +extern void cn1_debugger_check(struct ThreadLocalData* threadStateData, int line); + +#define __CN1_DEBUG_INFO(line) \ + do { \ + threadStateData->callStackLine[threadStateData->callStackOffset - 1] = (line); \ + if (__builtin_expect(cn1DebuggerActive, 0)) { \ + cn1_debugger_check(threadStateData, (line)); \ + } \ + } while (0) +#else #define __CN1_DEBUG_INFO(line) threadStateData->callStackLine[threadStateData->callStackOffset - 1] = line; +#endif // we need to throw stack overflow error but its unavailable here... /*#define ENTERING_CODENAME_ONE_METHOD(classIdNumber, methodIdNumber) { \ diff --git a/vm/ByteCodeTranslator/src/cn1_globals.m b/vm/ByteCodeTranslator/src/cn1_globals.m index f3ebb0ffe6..48a2a34d5b 100644 --- a/vm/ByteCodeTranslator/src/cn1_globals.m +++ b/vm/ByteCodeTranslator/src/cn1_globals.m @@ -1780,3 +1780,19 @@ JAVA_OBJECT cloneArray(JAVA_OBJECT array) { memcpy( (*arr).data, (*src).data, arr->length * byteSize); return (JAVA_OBJECT)arr; } + +#ifdef CN1_ON_DEVICE_DEBUG +// Default-zero flag. The iOS on-device-debug listener flips this to 1 once +// it has accepted a proxy connection. Weak so a stronger definition in +// cn1_debugger.m (iOS port) wins when that file is linked into the build. +__attribute__((weak)) volatile int cn1DebuggerActive = 0; + +// Weak stub that lets non-iOS / clean-output builds link even without the +// real listener. The strong implementation lives in +// Ports/iOSPort/nativeSources/cn1_debugger.m and is included in the iOS +// build when ios.onDeviceDebug=true. +__attribute__((weak)) void cn1_debugger_check(struct ThreadLocalData* threadStateData, int line) { + (void)threadStateData; + (void)line; +} +#endif diff --git a/vm/ByteCodeTranslator/src/com/codename1/tools/translator/ByteCodeClass.java b/vm/ByteCodeTranslator/src/com/codename1/tools/translator/ByteCodeClass.java index 953c78e8ad..da5a429592 100644 --- a/vm/ByteCodeTranslator/src/com/codename1/tools/translator/ByteCodeClass.java +++ b/vm/ByteCodeTranslator/src/com/codename1/tools/translator/ByteCodeClass.java @@ -1932,7 +1932,11 @@ public void setConcreteClass(String concreteClass) { public void setSourceFile(String sourceFile) { this.sourceFile = sourceFile; - } + } + + public String getSourceFile() { + return sourceFile; + } /** * @return the classOffset diff --git a/vm/ByteCodeTranslator/src/com/codename1/tools/translator/ByteCodeTranslator.java b/vm/ByteCodeTranslator/src/com/codename1/tools/translator/ByteCodeTranslator.java index 48d98d5729..c9ada57099 100644 --- a/vm/ByteCodeTranslator/src/com/codename1/tools/translator/ByteCodeTranslator.java +++ b/vm/ByteCodeTranslator/src/com/codename1/tools/translator/ByteCodeTranslator.java @@ -229,6 +229,9 @@ private static void handleCleanOutput(ByteCodeTranslator b, File[] sources, File if (System.getProperty("INCLUDE_NPE_CHECKS", "false").equals("true")) { replaceInFile(cn1Globals, "//#define CN1_INCLUDE_NPE_CHECKS", "#define CN1_INCLUDE_NPE_CHECKS"); } + if ("true".equalsIgnoreCase(System.getProperty("cn1.onDeviceDebug", "false"))) { + replaceInFile(cn1Globals, "//#define CN1_ON_DEVICE_DEBUG", "#define CN1_ON_DEVICE_DEBUG"); + } File cn1GlobalsC = new File(srcRoot, "cn1_globals.c"); copy(ByteCodeTranslator.class.getResourceAsStream("/cn1_globals.m"), Files.newOutputStream(cn1GlobalsC.toPath())); File nativeMethodsC = new File(srcRoot, "nativeMethods.c"); @@ -322,6 +325,9 @@ private static void handleIosOutput(ByteCodeTranslator b, File[] sources, File d if (System.getProperty("INCLUDE_NPE_CHECKS", "false").equals("true")) { replaceInFile(cn1Globals, "//#define CN1_INCLUDE_NPE_CHECKS", "#define CN1_INCLUDE_NPE_CHECKS"); } + if ("true".equalsIgnoreCase(System.getProperty("cn1.onDeviceDebug", "false"))) { + replaceInFile(cn1Globals, "//#define CN1_ON_DEVICE_DEBUG", "#define CN1_ON_DEVICE_DEBUG"); + } File cn1GlobalsM = new File(srcRoot, "cn1_globals.m"); copy(ByteCodeTranslator.class.getResourceAsStream("/cn1_globals.m"), Files.newOutputStream(cn1GlobalsM.toPath())); File nativeMethods = new File(srcRoot, "nativeMethods.m"); diff --git a/vm/ByteCodeTranslator/src/com/codename1/tools/translator/BytecodeMethod.java b/vm/ByteCodeTranslator/src/com/codename1/tools/translator/BytecodeMethod.java index 39550d7d4a..953d9fefa5 100644 --- a/vm/ByteCodeTranslator/src/com/codename1/tools/translator/BytecodeMethod.java +++ b/vm/ByteCodeTranslator/src/com/codename1/tools/translator/BytecodeMethod.java @@ -117,11 +117,25 @@ public static void setDependencyGraph(MethodDependencyGraph dependencyGraph) { static boolean optimizerOn; - + + /** + * When true, the translator emits extra metadata (per-frame locals-address + * tables, variable side-tables) and uses a debugger-aware form of + * __CN1_DEBUG_INFO. Toggled via the cn1.onDeviceDebug system property. + * Release builds leave this off and pay no overhead. + */ + static boolean onDeviceDebug; + static { String op = System.getProperty("optimizer"); optimizerOn = op == null || op.equalsIgnoreCase("on"); //optimizerOn = false; + + onDeviceDebug = "true".equalsIgnoreCase(System.getProperty("cn1.onDeviceDebug", "false")); + } + + public static boolean isOnDeviceDebug() { + return onDeviceDebug; } public boolean isBarebone() { @@ -884,6 +898,114 @@ private boolean hasLocalVariableWithIndex(char qualifier, int index) { } return false; } + + /** + * Emits a stack-allocated {@code void*} array containing the address of + * each JVM-local slot in the current frame, then stores the array pointer + * into the per-frame slot {@code callStackLocalsAddresses[offset-1]} that + * the debugger thread consults to read locals. + * + * Slot type is resolved from {@link #localVariables} first (the declared + * source-level locals) and falls back to method arguments for slots that + * have no debug info. Slots with no known type are emitted as NULL — the + * debugger will report them as unavailable. + * + * Only called from the non-barebone path; barebone methods carry no + * locals and bypass this entirely. + */ + private void appendLocalsAddressTable(StringBuilder b) { + if (maxLocals <= 0) { + return; + } + char[] slotQual = new char[maxLocals]; + java.util.Arrays.fill(slotQual, ' '); + for (LocalVariable lv : localVariables) { + int idx = lv.getIndex(); + if (idx >= 0 && idx < maxLocals) { + slotQual[idx] = lv.getQualifier(); + } + } + int slot = 0; + if (!staticMethod) { + if (slotQual[0] == ' ') { + slotQual[0] = 'o'; + } + slot = 1; + } + for (int i = 0; i < arguments.size() && slot < maxLocals; i++) { + ByteCodeMethodArg arg = arguments.get(i); + if (slotQual[slot] == ' ') { + slotQual[slot] = arg.getQualifier(); + } + slot++; + if (arg.isDoubleOrLong() && slot < maxLocals) { + slot++; + } + } + + // Leading newline: callers may have left the cursor mid-line after + // the "this" assignment when there are no other arguments, and the + // preprocessor requires #ifdef to be the first non-whitespace token + // on its line. + b.append("\n#ifdef CN1_ON_DEVICE_DEBUG\n"); + b.append(" void* __cn1_local_addrs[").append(maxLocals).append("] = { "); + for (int s = 0; s < maxLocals; s++) { + if (s > 0) { + b.append(", "); + } + char q = slotQual[s]; + switch (q) { + case 'o': + b.append("&locals[").append(s).append("].data.o"); + break; + case 'i': + case 'l': + case 'f': + case 'd': + b.append("&").append(q).append("locals_").append(s).append("_"); + break; + default: + b.append("0"); + break; + } + } + b.append(" };\n"); + b.append(" threadStateData->callStackLocalsAddresses[threadStateData->callStackOffset - 1] = __cn1_local_addrs;\n"); + b.append(" threadStateData->callStackFrameInfo[threadStateData->callStackOffset - 1] = &__cn1_finfo_").append(getMethodIdentifier()).append(";\n"); + b.append("#endif\n"); + } + + /** + * Emits the static per-method {@code cn1_frame_info} (and its inline + * {@code cn1_var_entry[]} side-table). Held at file scope so the + * per-frame pointer set up in {@link #appendLocalsAddressTable} stays + * valid for the program's lifetime. + * + * The side-table currently lists every declared local with line=0 + * meaning "always live"; refining live-range per source line is left + * for a follow-up so the proxy can hide locals before their declaration. + */ + private void appendFrameInfoStruct(StringBuilder b) { + String id = getMethodIdentifier(); + int classId = Parser.getClassOffset(clsName); + int methodId = methodOffset; + b.append("#ifdef CN1_ON_DEVICE_DEBUG\n"); + if (localVariables.isEmpty()) { + b.append("static const struct cn1_frame_info __cn1_finfo_").append(id).append(" = {\n"); + b.append(" ").append(classId).append(", ").append(methodId).append(", ").append(maxLocals).append(", 0, 0\n"); + b.append("};\n"); + } else { + b.append("static const struct cn1_var_entry __cn1_vars_").append(id).append("[] = {\n"); + for (LocalVariable lv : localVariables) { + b.append(" { 0, ").append(lv.getIndex()).append(", '").append(lv.getTypeCode()).append("' },\n"); + } + b.append("};\n"); + b.append("static const struct cn1_frame_info __cn1_finfo_").append(id).append(" = {\n"); + b.append(" ").append(classId).append(", ").append(methodId).append(", ").append(maxLocals).append(", ").append(localVariables.size()).append(", __cn1_vars_").append(id).append("\n"); + b.append("};\n"); + } + b.append("#endif\n"); + } private void fixUpBarebone() { for (Instruction i : instructions) { @@ -925,6 +1047,9 @@ public void appendMethodC(StringBuilder b) { if(nativeMethod) { return; } + if (onDeviceDebug && !eliminated) { + appendFrameInfoStruct(b); + } appendCMethodPrefix(b, ""); b.append(" {\n"); if(eliminated) { @@ -1097,6 +1222,9 @@ public void appendMethodC(StringBuilder b) { localsOffset++; } } + if (onDeviceDebug && !barebone) { + appendLocalsAddressTable(b); + } } else { if(synchronizedMethod) { if(staticMethod) { @@ -1451,7 +1579,19 @@ public void addLocalVariable(String name, String desc, String signature, Label s public void setSourceFile(String sourceFile) { this.sourceFile = sourceFile; - } + } + + public String getSourceFile() { + return sourceFile; + } + + public String getDesc() { + return desc; + } + + public Set getLocalVariables() { + return localVariables; + } public void addDebugInfo(int line) { if (disableDebugInfo) { diff --git a/vm/ByteCodeTranslator/src/com/codename1/tools/translator/Parser.java b/vm/ByteCodeTranslator/src/com/codename1/tools/translator/Parser.java index b1a454a128..a6cdd796a3 100644 --- a/vm/ByteCodeTranslator/src/com/codename1/tools/translator/Parser.java +++ b/vm/ByteCodeTranslator/src/com/codename1/tools/translator/Parser.java @@ -91,9 +91,84 @@ private static ByteCodeClass getClassByName(String name) { if(bc.getClsName().equals(name)) { return bc; } - } + } return null; } + + /** + * Resolves a class by name and returns its post-{@link #writeOutput} + * classOffset, or -1 if no class with that name exists. Used by the + * on-device-debug side-table emitter to wire stable IDs into the + * generated frame_info structs. + */ + public static int getClassOffset(String name) { + ByteCodeClass bc = getClassByName(name); + return bc == null ? -1 : bc.getClassOffset(); + } + + public static List getClasses() { + return classes; + } + + /** + * Writes the on-device-debug symbol sidecar (cn1-symbols.txt) to the + * output directory. Format is line-based ASCII for trivial parsing + * by the desktop proxy: + * version <n> + * class <classId> <clsName> <sourceFile> + * method <methodId> <classId> <methodName> <desc> + * line <methodId> <sourceLine> + * The proxy uses this to map JDWP class signatures and source-line + * breakpoints back onto the (classId, methodId, line) tuples the + * device wire protocol speaks. + */ + private static void writeSymbolSidecar(File outputDirectory) throws IOException { + File f = new File(outputDirectory, "cn1-symbols.txt"); + try (Writer w = new OutputStreamWriter(Files.newOutputStream(f.toPath()), StandardCharsets.UTF_8)) { + w.write("version\t1\n"); + for (ByteCodeClass bc : classes) { + String src = bc.getSourceFile(); + if (src == null) { + src = ""; + } + w.write("class\t" + bc.getClassOffset() + "\t" + bc.getClsName() + "\t" + src + "\n"); + } + for (ByteCodeClass bc : classes) { + int classId = bc.getClassOffset(); + for (BytecodeMethod m : bc.getMethods()) { + if (m.isEliminated()) { + continue; + } + String desc = m.getDesc(); + if (desc == null) { + desc = ""; + } + w.write("method\t" + m.getMethodOffset() + "\t" + classId + "\t" + m.getMethodName() + "\t" + desc + "\n"); + Set lines = new TreeSet<>(); + for (com.codename1.tools.translator.bytecodes.Instruction ins : m.getInstructions()) { + if (ins instanceof com.codename1.tools.translator.bytecodes.LineNumber) { + lines.add(((com.codename1.tools.translator.bytecodes.LineNumber)ins).getLine()); + } + } + for (Integer line : lines) { + w.write("line\t" + m.getMethodOffset() + "\t" + line + "\n"); + } + // Local variables: emitted as "always-live" scope until a + // follow-up resolves ASM labels to source lines properly. + // jdb tolerates this — uninitialised slots just show 0. + for (com.codename1.tools.translator.bytecodes.LocalVariable lv : m.getLocalVariables()) { + w.write("var\t" + m.getMethodOffset() + + "\t" + lv.getIndex() + + "\t" + lv.getOrigName() + + "\t" + lv.getDesc() + "\n"); + } + } + } + } + if (ByteCodeTranslator.verbose) { + System.out.println("Wrote on-device-debug symbol sidecar: " + f.getAbsolutePath()); + } + } private static void appendClassOffset(ByteCodeClass bc, List clsIds) { if(bc.getBaseClassObject() != null) { @@ -458,6 +533,10 @@ public static void writeOutput(File outputDirectory) throws Exception { writeFile(bc, outputDirectory, cos); } if (cos != null) cos.realClose(); + + if (BytecodeMethod.isOnDeviceDebug()) { + writeSymbolSidecar(outputDirectory); + } } } catch(Throwable t) { System.out.println("Error while working with the class: " + file); diff --git a/vm/ByteCodeTranslator/src/com/codename1/tools/translator/bytecodes/LineNumber.java b/vm/ByteCodeTranslator/src/com/codename1/tools/translator/bytecodes/LineNumber.java index c434cc9f91..80437a6681 100644 --- a/vm/ByteCodeTranslator/src/com/codename1/tools/translator/bytecodes/LineNumber.java +++ b/vm/ByteCodeTranslator/src/com/codename1/tools/translator/bytecodes/LineNumber.java @@ -37,6 +37,10 @@ public LineNumber(String sourceFile, int line) { this.line = line; } + public int getLine() { + return line; + } + @Override public void appendInstruction(StringBuilder b) { if(hasInstructions && (getMethod() == null || !getMethod().isDisableDebugInfo())) { diff --git a/vm/ByteCodeTranslator/src/com/codename1/tools/translator/bytecodes/LocalVariable.java b/vm/ByteCodeTranslator/src/com/codename1/tools/translator/bytecodes/LocalVariable.java index b1546d8e66..f84f67fe07 100644 --- a/vm/ByteCodeTranslator/src/com/codename1/tools/translator/bytecodes/LocalVariable.java +++ b/vm/ByteCodeTranslator/src/com/codename1/tools/translator/bytecodes/LocalVariable.java @@ -98,6 +98,17 @@ public void appendInstruction(StringBuilder b) { } } + /** + * Returns the JVM type-descriptor first character (I/J/F/D/Z/B/S/C for + * primitives, 'L' for objects, '[' for arrays). Distinct from + * {@link #getQualifier()}, which collapses byte/short/char/boolean/int + * onto 'i'. Used by the on-device-debug side-table so the proxy can + * present primitives at their declared narrow width. + */ + public char getTypeCode() { + return desc.charAt(0); + } + public char getQualifier() { switch (desc.charAt(0)) { case 'B' : @@ -125,6 +136,10 @@ public char getQualifier() { public String getOrigName() { return name; } + + public String getDesc() { + return desc; + } public String getVarName() { if(name.equals("this")) { diff --git a/vm/ByteCodeTranslator/src/nativeMethods.m b/vm/ByteCodeTranslator/src/nativeMethods.m index a40d77d9f7..fa8b7621a5 100644 --- a/vm/ByteCodeTranslator/src/nativeMethods.m +++ b/vm/ByteCodeTranslator/src/nativeMethods.m @@ -1283,7 +1283,14 @@ JAVA_INT java_lang_Object_hashCode___R_int(CODENAME_ONE_THREAD_STATE, JAVA_OBJEC i->callStackMethod = malloc(CN1_MAX_STACK_CALL_DEPTH * sizeof(int)); memset(i->callStackMethod, 0, CN1_MAX_STACK_CALL_DEPTH * sizeof(int)); - + +#ifdef CN1_ON_DEVICE_DEBUG + i->callStackLocalsAddresses = malloc(CN1_MAX_STACK_CALL_DEPTH * sizeof(void**)); + memset(i->callStackLocalsAddresses, 0, CN1_MAX_STACK_CALL_DEPTH * sizeof(void**)); + i->callStackFrameInfo = malloc(CN1_MAX_STACK_CALL_DEPTH * sizeof(struct cn1_frame_info*)); + memset(i->callStackFrameInfo, 0, CN1_MAX_STACK_CALL_DEPTH * sizeof(struct cn1_frame_info*)); +#endif + i->callStackOffset = 0; i->pendingHeapAllocations = malloc(PER_THREAD_ALLOCATION_COUNT * sizeof(void *)); @@ -1538,6 +1545,10 @@ JAVA_VOID java_lang_Thread_releaseThreadNativeResources___long(CODENAME_ONE_THRE free(head->callStackClass); free(head->callStackLine); free(head->callStackMethod); +#ifdef CN1_ON_DEVICE_DEBUG + free(head->callStackLocalsAddresses); + free(head->callStackFrameInfo); +#endif free(head->pendingHeapAllocations); free(head); nThreadsToKill--; From 56b9c0271c734fb0226f6af0fa804dc62d979e0f Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Fri, 22 May 2026 00:07:34 +0300 Subject: [PATCH 02/13] Address review feedback on on-device debugging - 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). --- .../Advanced-Topics-Under-The-Hood.asciidoc | 12 + .../On-Device-Debugging.asciidoc | 213 ++++++++---------- .../com/codename1/builders/IPhoneBuilder.java | 14 +- .../codename1/tools/translator/Parser.java | 4 - 4 files changed, 115 insertions(+), 128 deletions(-) diff --git a/docs/developer-guide/Advanced-Topics-Under-The-Hood.asciidoc b/docs/developer-guide/Advanced-Topics-Under-The-Hood.asciidoc index ddb9777463..e09d47dcdf 100644 --- a/docs/developer-guide/Advanced-Topics-Under-The-Hood.asciidoc +++ b/docs/developer-guide/Advanced-Topics-Under-The-Hood.asciidoc @@ -442,6 +442,18 @@ Only supported for App Store builds. See https://www.codenameone.com/developer-g |ios.generateSplashScreens |Boolean true/false defaults to false as of 5.0. Enable legacy generation of splash screen images for use when launching the app. These have been replaced now by the new launch storyboards. +|ios.onDeviceDebug +|Boolean true/false defaults to false. When `true`, the iOS build links a small JDWP listener thread (`cn1_debugger`) into the binary and the ParparVM translator emits source-line and locals metadata so a desktop proxy can serve the running app to any JDWP-speaking debugger. Has no effect on release builds. See the link:#_ondevice_debugging_ios[On-Device Debugging chapter] for the full flow. + +|ios.onDeviceDebug.proxyHost +|Hostname or IP address the device-side listener dials to reach the desktop proxy. Default `127.0.0.1` (correct for iOS Simulator). For a physical device, set this to the developer laptop's LAN IP. Has no effect unless `ios.onDeviceDebug=true`. + +|ios.onDeviceDebug.proxyPort +|TCP port on `ios.onDeviceDebug.proxyHost` where the proxy is listening for the device. Default `55333`. Has no effect unless `ios.onDeviceDebug=true`. + +|ios.onDeviceDebug.waitForAttach +|Boolean true/false defaults to false. When `true`, the app blocks at startup until the proxy connects and the IDE issues a Resume. Useful when the breakpoint to investigate fires during app boot. Has no effect unless `ios.onDeviceDebug=true`. + |desktop.width |Width in pixels for the form in desktop builds, will be doubled for retina grade displays. Defaults to 800. diff --git a/docs/developer-guide/On-Device-Debugging.asciidoc b/docs/developer-guide/On-Device-Debugging.asciidoc index 41c81df8ea..b84dd9a4a6 100644 --- a/docs/developer-guide/On-Device-Debugging.asciidoc +++ b/docs/developer-guide/On-Device-Debugging.asciidoc @@ -1,87 +1,59 @@ == On-Device Debugging (iOS) The Codename One simulator runs your app on the JVM, so the IDE's -normal Java debugger works against it directly. Some bugs only appear +normal Java debugger works against it directly. Some bugs only appear on a real device — ParparVM's threading model, iOS-specific native behaviour, performance characteristics on real hardware, memory pressure under iOS background limits, and timing around UIKit -interactions are common examples. On-device debugging lets you keep -using a standard Java debugger (jdb, IntelliJ IDEA, VS Code, Eclipse, -NetBeans, anything that speaks JDWP) against the running iOS app on -either the iOS Simulator or a physical device. +interactions are common examples. On-device debugging lets you keep +using a standard Java debugger (jdb, IntelliJ IDEA, VS Code, +anything that speaks JDWP) against the running iOS app on either +the iOS Simulator or a physical device. -It works by adding a tiny listener thread to the ParparVM-generated -iOS binary. The app dials out to a desktop proxy over Wi-Fi/loopback; -the proxy speaks JDWP on the other side, so to the IDE everything -looks like attaching to a normal remote JVM. +It works by adding a small listener thread to the +ParparVM-generated iOS binary. The app dials out to a desktop proxy +over Wi-Fi or loopback; the proxy speaks JDWP on the other side, so +to the IDE everything looks like attaching to a normal remote JVM. === When to use it -* You can reproduce a bug on the simulator's Xcode build but not in - the JavaSE simulator — for instance a native-call issue, a layout - glitch that only shows up on iOS modern theme, or a JIT-vs.-AOT - numerical discrepancy. -* You want to single-step through real ParparVM-translated code and - read the values its variables actually hold at runtime. +* You can reproduce a bug on a built iOS app but not in the + Codename One simulator — for instance a native-call issue, a + layout glitch that only shows up on iOS modern theme, or an + AOT-vs.-JIT numerical discrepancy. +* You want to single-step through real ParparVM-translated code + and read the values its variables actually hold at runtime. * You want to inspect object state on a tethered device while - reproducing a problem a customer reported, without having to add - log lines and rebuild. + reproducing a problem a customer reported, without having to + add log lines and rebuild. -If your bug is reproducible in the simulator, stay in the simulator — -its debugger is faster and has fewer limitations. - -=== Prerequisites - -* Codename One {cn1-release-version} or later. -* macOS with Xcode installed. The iOS app must be built locally - (Xcode Simulator or a tethered device), or via the cloud build - configured with the on-device-debug target. -* A JDWP-capable debugger. `jdb` ships with the JDK; IntelliJ - IDEA, VS Code (Debugger for Java), Eclipse and NetBeans all work. -* For physical devices: the device and your laptop need to be on the - same Wi-Fi network. For iOS Simulator: localhost is sufficient. +If your bug is reproducible in the simulator, stay in the simulator +— its debugger is faster and has fewer limitations. === Quick start -==== 1. Enable the build hint +==== 1. Enable the build hints -Add to your project's `common/codenameone_settings.properties`: +Set the following build hints on your project: [source,properties] ---- -codename1.arg.ios.onDeviceDebug=true -codename1.arg.ios.onDeviceDebug.proxyHost=127.0.0.1 -codename1.arg.ios.onDeviceDebug.proxyPort=55333 +ios.onDeviceDebug=true +ios.onDeviceDebug.proxyHost=127.0.0.1 +ios.onDeviceDebug.proxyPort=55333 # Optional: block the app at startup until the proxy is attached. -# codename1.arg.ios.onDeviceDebug.waitForAttach=true ----- - -For a physical device, set `proxyHost` to the laptop's LAN IP -(`ifconfig | grep "inet "`) instead of `127.0.0.1`. - -==== 2. Build the iOS app - -Locally: - -[source,bash] ----- -mvn package -DskipTests \ - -Dcodename1.platform=ios \ - -Dcodename1.buildTarget=ios-source +# ios.onDeviceDebug.waitForAttach=true ---- -This generates an Xcode project under `ios/target/...-ios-source/`. -Open `.xcodeproj` in Xcode and run on the iOS Simulator -or a tethered device. +For a physical device, set `ios.onDeviceDebug.proxyHost` to the +laptop's LAN IP (run `ifconfig | grep "inet "`) instead of +`127.0.0.1`. The simulator can always use `127.0.0.1`. -Via the cloud build, use the `ios-on-device-debug` build target: +==== 2. Build the app as you normally do -[source,bash] ----- -mvn package -DskipTests \ - -Dcodename1.platform=ios \ - -Dcodename1.buildTarget=ios-on-device-debug ----- +Either the local Xcode path (`cn1:buildIosXcodeProject`) or a cloud +build (`cn1:buildIosOnDeviceDebug`) will produce an app instrumented +for on-device debugging once the build hints are set. ==== 3. Start the proxy @@ -100,8 +72,8 @@ On-device-debug proxy starting: jdwp : listening on tcp://0.0.0.0:8000 Next steps: - 1. Open the generated Xcode project in iOS Simulator or on device. - 2. Once the app boots it will dial in and the proxy will log a HELLO. + 1. Run the built app on the simulator or device. + 2. Once it boots, it'll dial in and the proxy will log a HELLO. 3. Attach a debugger: jdb -attach localhost:8000 ---- @@ -116,16 +88,16 @@ For `jdb`: jdb -attach localhost:8000 ---- -For IntelliJ IDEA: *Run → Edit Configurations…* → *+* → *Remote JVM -Debug*. Set the host to `localhost`, port to `8000`, leave the rest -at the defaults, and click *Debug*. +For IntelliJ IDEA: *Run* → *Edit Configurations…* → *+* → *Remote +JVM Debug*. Set the host to `localhost`, port to `8000`, leave the +rest at the defaults, and click *Debug*. For VS Code (Debugger for Java extension): add a launch -configuration of type `java` with `"request": "attach"`, `"hostName": -"localhost"`, `"port": 8000`. +configuration of type `java` with `"request": "attach"`, +`"hostName": "localhost"`, `"port": 8000`. -When the app starts running on the device it will connect to the -proxy and the IDE will see a fresh suspended VM. Set breakpoints, +When the app starts running on the device, it'll connect to the +proxy and the IDE will see a fresh suspended VM. Set breakpoints, step, inspect, just like a normal remote attach. === What works today @@ -133,92 +105,93 @@ step, inspect, just like a normal remote attach. * Class loading: the IDE sees every class in your build. * Line breakpoints in user code and in the Codename One framework. * Step into / over / out. -* Stack walking — full Java stack including the cn1 framework - frames above your code. +* Stack walking — full Java stack including the Codename One + framework frames above your code. * Inspecting primitive locals (int, long, float, double, boolean, byte, char, short). * Inspecting `java.lang.String` values directly. -* Inspecting object references — class name and identity, plus the - ability to drill in via the IDE's variables view. +* Inspecting object references — class name and identity, plus + the ability to drill in via the IDE's variables view. * Pause and resume from the IDE. === Known limitations -* *Method invocation from the debugger is not supported.* The IDE's - "evaluate expression" feature can read existing values but cannot - invoke methods on objects (e.g. `myList.size()`). Object identity, - field access, and primitive evaluation are supported. +* *Method invocation from the debugger isn't supported.* The IDE's + "evaluate expression" feature can read existing values but can't + invoke methods on objects (for example, `myList.size()`). Object + identity, field access, and primitive evaluation work. * *Watch expressions* that don't require method calls work; those that do return an error. -* *Hot-swap* is not supported. Code changes require a rebuild and +* *Hot-swap* isn't supported. Code changes require a rebuild and reinstall of the app. * *Hot threads* — if your app is doing heavy work on a non-EDT thread when a breakpoint hits, that other thread continues - running. Only the thread that hit the breakpoint suspends by - default. Use the IDE's "suspend VM" if you need a fully frozen + running. Only the thread that hit the breakpoint suspends by + default. Use the IDE's "suspend VM" if you need a fully frozen picture. * *Local variable names* are present only when the user's Java - sources were compiled with `-g`. Codename One's archetypes do + sources were compiled with `-g`. Codename One's archetypes do compile with debug info by default; if a variable shows up as - `v1`, `v2`, ... it means that particular class was compiled - without debug info. + `v1`, `v2`, ... that's because that particular class was + compiled without debug info. === Performance -The on-device-debug instrumentation is gated by a compile-time flag -that is *only set in debug builds*. Release builds (`ios-release`) -are completely unaffected — no listener thread, no per-line callback, +The on-device-debug instrumentation is gated by a compile-time +flag that's *only set in debug builds*. Release builds are +completely unaffected — no listener thread, no per-line callback, no extra metadata in the binary. In debug builds, the per-line callback adds a predictable -load+branch around the macro that ParparVM already emits to record -source line numbers for stack traces. When no debugger is attached -the runtime cost is close to zero. When a debugger *is* attached, -expect numerical inner loops to run on the order of two to three -times slower than a release build — this is normal and matches the -overhead of `-g`-style debugging on other native VMs. +load+branch around the macro that ParparVM already emits to +record source line numbers for stack traces. When no debugger is +attached, the runtime cost is close to zero. When a debugger +*is* attached, expect numerical inner loops to run on the order +of two to three times slower than a release build — this is +normal and matches the overhead of `-g`-style debugging on +other native VMs. === Troubleshooting -==== The proxy reports "device disconnected" before my breakpoint fires +==== The proxy reports "device disconnected" before the breakpoint fires -The app may be crashing before it reaches user code. Run the app -from Xcode and watch the console — installSignalHandlers will -intercept native crashes and rethrow them as Java exceptions you can -see. If the connection drops mid-debug, check the proxy log for a -device-side I/O error. +The app might be crashing before it reaches user code. Run the +app from Xcode and watch the console — `installSignalHandlers` +will intercept native crashes and rethrow them as Java exceptions +you can see. If the connection drops mid-debug, check the proxy +log for a device-side I/O error. ==== "Connection refused" when the device tries to reach the proxy -The device's `CN1ProxyHost` is wrong, or a firewall is blocking the -port. Verify: +The device's `ios.onDeviceDebug.proxyHost` value is wrong, or a +firewall is blocking the port. Verify: * The simulator can always reach `127.0.0.1`. -* A physical device needs your laptop's *LAN IP* (not `localhost`), - and the laptop's firewall must allow incoming connections on the - configured `proxyPort` (default `55333`). -* macOS may prompt the first time the proxy listens — accept the - Allow incoming connections dialog. +* A physical device needs the laptop's *LAN IP* (not `localhost`), + and the laptop's firewall must allow incoming connections on + the configured port (default `55333`). +* macOS will prompt the first time the proxy listens — accept the + *Allow incoming connections* dialog. ==== jdb reports "Internal exception: Unexpected JDWP Error: 100" -This typically means an unsupported JDWP feature was invoked — most -commonly an attempt to call a method on an object from the debugger -(see "Method invocation from the debugger is not supported" above). -The debug session itself is fine; rerun the operation as a value -read (e.g. expand the object in the variables view) instead of a -method call. +This typically means an unsupported JDWP feature was invoked — +most commonly an attempt to call a method on an object from the +debugger (see "Method invocation from the debugger isn't +supported" above). The debug session itself is fine; rerun the +operation as a value read (for example, expand the object in the +variables view) instead of a method call. ==== The app launches but the breakpoint never fires -Confirm the build hint actually took effect. Inspect the generated -Xcode project's `cn1_globals.h` and look for `#define -CN1_ON_DEVICE_DEBUG` *without* a leading `//`. If the line is still -commented, the build was a release/non-debug build, or -`ios.onDeviceDebug` wasn't set in `codenameone_settings.properties`. +Confirm the build hint actually took effect. Inspect the +generated Xcode project's `cn1_globals.h` and look for `#define +CN1_ON_DEVICE_DEBUG` *without* a leading `//`. If the line is +still commented out, the build was a release/non-debug build, or +`ios.onDeviceDebug` wasn't set as a build hint. -==== I want the app to wait for me to attach before running +==== Make the app wait before running so the IDE can attach first -Set `codename1.arg.ios.onDeviceDebug.waitForAttach=true`. The app -will block at startup until the proxy is connected and the IDE has -issued a Resume. +Set `ios.onDeviceDebug.waitForAttach=true`. The app will block +at startup until the proxy is connected and the IDE has issued +the resume command. diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java index 3cb42937b8..17d18305c0 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java @@ -1679,8 +1679,12 @@ public void usesClassMethod(String cls, String method) { // On-device-debug toggle: tells the translator to emit per-frame // locals-address tables, the cn1_frame_info side-tables, and to // flip the CN1_ON_DEVICE_DEBUG #define in cn1_globals.h so the - // generated Xcode build links the listener thread. - String onDeviceDebug = Boolean.valueOf(request.getArg("ios.onDeviceDebug", "false")) ? "true" : "false"; + // generated Xcode build links the listener thread. Force-off on + // release builds so a stray hint in codenameone_settings.properties + // can't leak the debug listener into an App Store binary. + boolean isReleaseBuild = !request.getArg("ios.buildType", "debug").equals("debug"); + String onDeviceDebug = !isReleaseBuild + && Boolean.valueOf(request.getArg("ios.onDeviceDebug", "false")) ? "true" : "false"; if (enableGalleryMultiselect && photoLibraryUsage) { @@ -2766,8 +2770,10 @@ public boolean accept(File file, String string) { // On-device-debug: drop the proxy host/port into Info.plist so // cn1_debugger.m can read them at app boot without needing the - // build to also patch source files. - if ("true".equalsIgnoreCase(request.getArg("ios.onDeviceDebug", "false"))) { + // build to also patch source files. Skipped on release builds for + // the same reason as the translator gate above. + if ("true".equalsIgnoreCase(request.getArg("ios.onDeviceDebug", "false")) + && "debug".equals(request.getArg("ios.buildType", "debug"))) { String proxyHost = request.getArg("ios.onDeviceDebug.proxyHost", "127.0.0.1"); String proxyPort = request.getArg("ios.onDeviceDebug.proxyPort", "55333"); String waitForAttach = "true".equalsIgnoreCase( diff --git a/vm/ByteCodeTranslator/src/com/codename1/tools/translator/Parser.java b/vm/ByteCodeTranslator/src/com/codename1/tools/translator/Parser.java index a6cdd796a3..4b4675207a 100644 --- a/vm/ByteCodeTranslator/src/com/codename1/tools/translator/Parser.java +++ b/vm/ByteCodeTranslator/src/com/codename1/tools/translator/Parser.java @@ -106,10 +106,6 @@ public static int getClassOffset(String name) { return bc == null ? -1 : bc.getClassOffset(); } - public static List getClasses() { - return classes; - } - /** * Writes the on-device-debug symbol sidecar (cn1-symbols.txt) to the * output directory. Format is line-based ASCII for trivial parsing From 0683cc0d18185f94ab4d709c44b22a139047af19 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Fri, 22 May 2026 00:27:23 +0300 Subject: [PATCH 03/13] Avoid proselint Diacritical false positive on 'Resume' --- docs/developer-guide/Advanced-Topics-Under-The-Hood.asciidoc | 2 +- docs/developer-guide/On-Device-Debugging.asciidoc | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/developer-guide/Advanced-Topics-Under-The-Hood.asciidoc b/docs/developer-guide/Advanced-Topics-Under-The-Hood.asciidoc index e09d47dcdf..53117b2857 100644 --- a/docs/developer-guide/Advanced-Topics-Under-The-Hood.asciidoc +++ b/docs/developer-guide/Advanced-Topics-Under-The-Hood.asciidoc @@ -452,7 +452,7 @@ Only supported for App Store builds. See https://www.codenameone.com/developer-g |TCP port on `ios.onDeviceDebug.proxyHost` where the proxy is listening for the device. Default `55333`. Has no effect unless `ios.onDeviceDebug=true`. |ios.onDeviceDebug.waitForAttach -|Boolean true/false defaults to false. When `true`, the app blocks at startup until the proxy connects and the IDE issues a Resume. Useful when the breakpoint to investigate fires during app boot. Has no effect unless `ios.onDeviceDebug=true`. +|Boolean true/false defaults to false. When `true`, the app blocks at startup until the proxy connects and the IDE tells the VM to continue. Useful when the breakpoint to investigate fires during app boot. Has no effect unless `ios.onDeviceDebug=true`. |desktop.width |Width in pixels for the form in desktop builds, will be doubled for retina grade displays. Defaults to 800. diff --git a/docs/developer-guide/On-Device-Debugging.asciidoc b/docs/developer-guide/On-Device-Debugging.asciidoc index b84dd9a4a6..9569e04588 100644 --- a/docs/developer-guide/On-Device-Debugging.asciidoc +++ b/docs/developer-guide/On-Device-Debugging.asciidoc @@ -193,5 +193,5 @@ still commented out, the build was a release/non-debug build, or ==== Make the app wait before running so the IDE can attach first Set `ios.onDeviceDebug.waitForAttach=true`. The app will block -at startup until the proxy is connected and the IDE has issued -the resume command. +at startup until the proxy is connected and the IDE tells the +VM to continue. From 54fc1e8ac2c3d389f3ff9899b41462a181244b35 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Fri, 22 May 2026 08:52:25 +0300 Subject: [PATCH 04/13] On-device debug UX: waiting overlay, non-blocking start, JDWP reattach 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). --- .gitignore | 2 + .../nativeSources/CodenameOne_GLAppDelegate.m | 24 +- Ports/iOSPort/nativeSources/cn1_debugger.h | 33 ++- Ports/iOSPort/nativeSources/cn1_debugger.m | 216 ++++++++++++++++-- .../com/codename1/debug/proxy/JdwpServer.java | 75 ++++-- 5 files changed, 293 insertions(+), 57 deletions(-) diff --git a/.gitignore b/.gitignore index 48462e4601..b32c620726 100644 --- a/.gitignore +++ b/.gitignore @@ -88,6 +88,8 @@ pom.xml.tag !.brokk/project.properties dependency-reduced-pom.xml +/artifacts/ + # Hugo website generated artifacts /docs/website/.hugo_build.lock /docs/website/hugo_stats.json diff --git a/Ports/iOSPort/nativeSources/CodenameOne_GLAppDelegate.m b/Ports/iOSPort/nativeSources/CodenameOne_GLAppDelegate.m index da18a1bc09..1ac0282466 100644 --- a/Ports/iOSPort/nativeSources/CodenameOne_GLAppDelegate.m +++ b/Ports/iOSPort/nativeSources/CodenameOne_GLAppDelegate.m @@ -326,10 +326,10 @@ - (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:( // app will throw an NPE. installSignalHandlers(); #ifdef CN1_ON_DEVICE_DEBUG - // Bring up the on-device-debug listener BEFORE the VM callback so the - // proxy can already be attached when user code starts running. The - // listener spawns its own thread and may block boot if Info.plist - // sets CN1ProxyWaitForAttach=YES. + // Spawn the on-device-debug listener thread. Non-blocking: if + // CN1ProxyWaitForAttach=YES the function also installs a translucent + // overlay UIWindow so the user sees a "Waiting for debugger..." message + // instead of the launch splash while the wait is in progress. extern void cn1_debugger_start(void); cn1_debugger_start(); #endif @@ -349,12 +349,26 @@ - (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:( } } } +#ifdef CN1_ON_DEVICE_DEBUG + // Defer the VM callback until the on-device-debug proxy reports an IDE + // has attached (if CN1ProxyWaitForAttach=YES). Otherwise this fires + // synchronously and behaves identically to the non-debug build. + extern void cn1_debugger_run_when_ready(void (^onReady)(void)); + id locationValueDeferred = [launchOptions objectForKey:UIApplicationLaunchOptionsLocationKey]; + cn1_debugger_run_when_ready(^{ + com_codename1_impl_ios_IOSImplementation_callback__(CN1_THREAD_GET_STATE_PASS_SINGLE_ARG); + if (locationValueDeferred) { + com_codename1_impl_ios_IOSImplementation_appDidLaunchWithLocation__(CN1_THREAD_GET_STATE_PASS_SINGLE_ARG); + } + }); +#else com_codename1_impl_ios_IOSImplementation_callback__(CN1_THREAD_GET_STATE_PASS_SINGLE_ARG); - + id locationValue = [launchOptions objectForKey:UIApplicationLaunchOptionsLocationKey]; if (locationValue) { com_codename1_impl_ios_IOSImplementation_appDidLaunchWithLocation__(CN1_THREAD_GET_STATE_PASS_SINGLE_ARG); } +#endif #ifdef INCLUDE_CN1_BACKGROUND_FETCH [application setMinimumBackgroundFetchInterval:UIApplicationBackgroundFetchIntervalMinimum]; diff --git a/Ports/iOSPort/nativeSources/cn1_debugger.h b/Ports/iOSPort/nativeSources/cn1_debugger.h index 9de20ad84d..ccee85c254 100644 --- a/Ports/iOSPort/nativeSources/cn1_debugger.h +++ b/Ports/iOSPort/nativeSources/cn1_debugger.h @@ -16,18 +16,33 @@ #ifdef CN1_ON_DEVICE_DEBUG /** - * Boots the on-device-debug listener thread. Reads the desktop proxy host - * and port from Info.plist keys CN1ProxyHost and CN1ProxyPort, opens an - * outbound TCP connection, sends a HELLO event, and services commands - * (set/clear breakpoint, resume, step, get stack, get locals) in a loop. + * Boots the on-device-debug listener thread (non-blocking). Reads the + * desktop proxy host and port from Info.plist keys CN1ProxyHost / + * CN1ProxyPort, spawns a background thread that opens an outbound TCP + * connection, sends a HELLO event, and services commands (set/clear + * breakpoint, resume, step, get stack, get locals) in a loop. * - * Called from CodenameOne_GLAppDelegate.m's application:didFinishLaunching - * after signal handlers are in place but before the Java VM callback. If - * Info.plist has CN1ProxyWaitForAttach=YES the function blocks until the - * proxy connects and sends RESUME; otherwise it returns immediately and the - * listener thread retries the connection in the background. + * Returns immediately. If Info.plist has CN1ProxyWaitForAttach=YES, the + * function also installs a "Waiting for debugger" overlay UIWindow so the + * user sees something other than the splash while the wait is in progress; + * the overlay is dismissed automatically when {@link + * cn1_debugger_run_when_ready} fires its block. */ extern void cn1_debugger_start(void); +#ifdef __BLOCKS__ +/** + * Defers the VM callback until the proxy reports the IDE has attached, so + * the AppDelegate can keep `didFinishLaunchingWithOptions` returning + * promptly and let UIKit draw the waiting overlay. + * + * If CN1ProxyWaitForAttach=NO (or the on-device-debug listener isn't + * configured), the block is invoked synchronously on the calling thread. + * Otherwise the block is stored and the proxy listener invokes it on the + * main queue once it receives the first RESUME from the desktop proxy. + */ +extern void cn1_debugger_run_when_ready(void (^onReady)(void)); +#endif + #endif // CN1_ON_DEVICE_DEBUG #endif // CN1_DEBUGGER_H diff --git a/Ports/iOSPort/nativeSources/cn1_debugger.m b/Ports/iOSPort/nativeSources/cn1_debugger.m index 052f210b58..340676961e 100644 --- a/Ports/iOSPort/nativeSources/cn1_debugger.m +++ b/Ports/iOSPort/nativeSources/cn1_debugger.m @@ -83,9 +83,26 @@ static int g_proxyFd = -1; static pthread_mutex_t g_writeMutex = PTHREAD_MUTEX_INITIALIZER; + +// Wait-for-attach state. cn1_debugger_run_when_ready stashes the VM-callback +// block here; the listener thread invokes it on the main queue once the +// proxy has acked the IDE attach (the first CMD_RESUME). Releasing the wait +// on the main thread keeps UIKit free to draw the overlay during the wait. static int g_waitForAttach = 0; +static int g_attachReady = 0; // set when the IDE has signalled ready +static dispatch_block_t g_onReadyBlock = nil; static pthread_mutex_t g_attachMutex = PTHREAD_MUTEX_INITIALIZER; -static pthread_cond_t g_attachCond = PTHREAD_COND_INITIALIZER; + +// "Waiting for debugger" overlay view. Owned by cn1_debugger; shown from +// cn1_debugger_start when waitForAttach is set, dismissed once the IDE has +// attached. Installed as a subview of the app's keyWindow root view rather +// than as a separate UIWindow because iOS 13+ scene-based apps (which +// Codename One is by default) refuse to display non-scene UIWindows. +static UIView* g_waitOverlay = nil; + +// Forward declaration; callers in the command dispatch sit above the +// definition further down the file. +static void cn1_debugger_fire_ready_block_if_pending(void); /* --------------------------------------------------------------------- */ /* Breakpoint hash. Open-addressed, lock-free reads via atomic 64-bit */ @@ -530,12 +547,10 @@ static int handleCommand(uint8_t cmd, const uint8_t* payload, uint32_t len) { int64_t tid = ((int64_t)ntohl(hi) << 32) | (uint32_t)ntohl(lo); if (tid == 0) resumeAll(1); else resumeThreadById(tid, 1); } - // The first RESUME after attach also releases cn1_debugger_start - // if it was waiting. - pthread_mutex_lock(&g_attachMutex); - g_waitForAttach = 0; - pthread_cond_broadcast(&g_attachCond); - pthread_mutex_unlock(&g_attachMutex); + // The first RESUME after attach also fires the VM-callback the + // AppDelegate registered via cn1_debugger_run_when_ready, so the + // splash / waiting overlay gives way to the user app. + cn1_debugger_fire_ready_block_if_pending(); return 0; } case CMD_STEP: { @@ -671,10 +686,9 @@ static int handleCommand(uint8_t cmd, const uint8_t* payload, uint32_t len) { } if (fd < 0) { NSLog(@"cn1_debugger: could not connect to %@:%d, giving up", host, port); - pthread_mutex_lock(&g_attachMutex); - g_waitForAttach = 0; - pthread_cond_broadcast(&g_attachCond); - pthread_mutex_unlock(&g_attachMutex); + // Release the AppDelegate's deferred VM callback so the app + // boots even if the proxy never comes up. + cn1_debugger_fire_ready_block_if_pending(); return NULL; } int nodelay = 1; @@ -712,17 +726,158 @@ static int handleCommand(uint8_t cmd, const uint8_t* payload, uint32_t len) { NSLog(@"cn1_debugger: proxy connection closed"); cn1DebuggerActive = 0; - resumeAll(-1); + resumeAll(/*preserveStep*/ 0); close(fd); g_proxyFd = -1; - pthread_mutex_lock(&g_attachMutex); - g_waitForAttach = 0; - pthread_cond_broadcast(&g_attachCond); - pthread_mutex_unlock(&g_attachMutex); + // If the AppDelegate is still waiting for an IDE attach, release it + // so the app can boot even after a failed/cancelled debug session. + cn1_debugger_fire_ready_block_if_pending(); } return NULL; } +/* --------------------------------------------------------------------- */ +/* Wait-for-attach plumbing. Non-blocking: the AppDelegate installs the */ +/* "Waiting" overlay during didFinishLaunching, registers its VM-start */ +/* completion block via cn1_debugger_run_when_ready, and returns */ +/* promptly so UIKit can draw the overlay. The listener thread invokes */ +/* the completion on the main queue once the proxy reports the IDE has */ +/* attached (via CMD_RESUME). */ +/* --------------------------------------------------------------------- */ + +static UIView* cn1_debugger_active_host_view(void) { + UIWindow* w = nil; + if (@available(iOS 13.0, *)) { + for (UIScene* s in UIApplication.sharedApplication.connectedScenes) { + if ([s isKindOfClass:[UIWindowScene class]]) { + for (UIWindow* candidate in ((UIWindowScene*)s).windows) { + if (candidate.isKeyWindow) { w = candidate; break; } + } + if (!w) { + UIWindow* anyVisible = ((UIWindowScene*)s).windows.firstObject; + if (anyVisible) { w = anyVisible; break; } + } + } + if (w) break; + } + } + if (!w) w = UIApplication.sharedApplication.keyWindow; + if (!w) w = UIApplication.sharedApplication.windows.firstObject; + return w ? w.rootViewController.view : nil; +} + +static void cn1_debugger_install_wait_overlay_now(void); // forward +static int g_overlayInstallAttempts = 0; + +static void cn1_debugger_try_install_wait_overlay(void) { + UIView* host = cn1_debugger_active_host_view(); + if (host == nil) { + // didFinishLaunching may not have set up the rootViewController yet; + // retry on the next main-queue tick a few times. + if (g_overlayInstallAttempts++ < 50) { + dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(50 * NSEC_PER_MSEC)), + dispatch_get_main_queue(), ^{ + cn1_debugger_try_install_wait_overlay(); + }); + } + return; + } + cn1_debugger_install_wait_overlay_now(); +} + +static void cn1_debugger_install_wait_overlay_now(void) { + if (g_waitOverlay != nil) return; + UIView* host = cn1_debugger_active_host_view(); + if (host == nil) return; + + UIView* overlay = [[UIView alloc] initWithFrame:host.bounds]; + overlay.autoresizingMask = UIViewAutoresizingFlexibleWidth | UIViewAutoresizingFlexibleHeight; + overlay.backgroundColor = [UIColor colorWithWhite:0.0 alpha:0.85]; + + UIActivityIndicatorView* spin; + if (@available(iOS 13.0, *)) { + spin = [[UIActivityIndicatorView alloc] initWithActivityIndicatorStyle:UIActivityIndicatorViewStyleLarge]; + } else { + spin = [[UIActivityIndicatorView alloc] initWithActivityIndicatorStyle:UIActivityIndicatorViewStyleWhiteLarge]; + } + spin.color = [UIColor whiteColor]; + [spin startAnimating]; + spin.translatesAutoresizingMaskIntoConstraints = NO; + [overlay addSubview:spin]; + + UILabel* lbl = [[UILabel alloc] init]; + lbl.text = @"Waiting for debugger to attach…"; + lbl.textColor = [UIColor whiteColor]; + lbl.textAlignment = NSTextAlignmentCenter; + lbl.numberOfLines = 0; + lbl.font = [UIFont systemFontOfSize:18 weight:UIFontWeightMedium]; + lbl.translatesAutoresizingMaskIntoConstraints = NO; + [overlay addSubview:lbl]; + + UILabel* sub = [[UILabel alloc] init]; + NSDictionary* info = [[NSBundle mainBundle] infoDictionary]; + NSString* host_s = info[@"CN1ProxyHost"] ?: @"?"; + NSNumber* portN = info[@"CN1ProxyPort"]; + sub.text = [NSString stringWithFormat:@"Proxy: %@:%@", host_s, portN ?: @"?"]; + sub.textColor = [UIColor colorWithWhite:0.85 alpha:1.0]; + sub.textAlignment = NSTextAlignmentCenter; + sub.font = [UIFont systemFontOfSize:13]; + sub.translatesAutoresizingMaskIntoConstraints = NO; + [overlay addSubview:sub]; + + [host addSubview:overlay]; + overlay.translatesAutoresizingMaskIntoConstraints = NO; + [NSLayoutConstraint activateConstraints:@[ + [overlay.leadingAnchor constraintEqualToAnchor:host.leadingAnchor], + [overlay.trailingAnchor constraintEqualToAnchor:host.trailingAnchor], + [overlay.topAnchor constraintEqualToAnchor:host.topAnchor], + [overlay.bottomAnchor constraintEqualToAnchor:host.bottomAnchor], + [spin.centerXAnchor constraintEqualToAnchor:overlay.centerXAnchor], + [spin.centerYAnchor constraintEqualToAnchor:overlay.centerYAnchor constant:-32], + [lbl.centerXAnchor constraintEqualToAnchor:overlay.centerXAnchor], + [lbl.topAnchor constraintEqualToAnchor:spin.bottomAnchor constant:16], + [lbl.widthAnchor constraintLessThanOrEqualToAnchor:overlay.widthAnchor multiplier:0.85], + [sub.centerXAnchor constraintEqualToAnchor:overlay.centerXAnchor], + [sub.topAnchor constraintEqualToAnchor:lbl.bottomAnchor constant:8] + ]]; + + g_waitOverlay = overlay; +} + +static void cn1_debugger_install_wait_overlay(void) { + dispatch_async(dispatch_get_main_queue(), ^{ + cn1_debugger_try_install_wait_overlay(); + }); +} + +static void cn1_debugger_dismiss_wait_overlay(void) { + dispatch_async(dispatch_get_main_queue(), ^{ + if (g_waitOverlay == nil) return; + [g_waitOverlay removeFromSuperview]; + g_waitOverlay = nil; + }); +} + +/* + * Invoked when the proxy confirms the IDE is ready. Fires the pending + * VM-callback block on the main queue and dismisses the overlay. Safe to + * call multiple times; only the first call has effect. + */ +static void cn1_debugger_fire_ready_block_if_pending(void) { + dispatch_block_t block = nil; + pthread_mutex_lock(&g_attachMutex); + if (!g_attachReady) { + g_attachReady = 1; + block = g_onReadyBlock; + g_onReadyBlock = nil; + } + pthread_mutex_unlock(&g_attachMutex); + cn1_debugger_dismiss_wait_overlay(); + if (block) { + dispatch_async(dispatch_get_main_queue(), block); + } +} + void cn1_debugger_start(void) { NSDictionary* info = [[NSBundle mainBundle] infoDictionary]; NSString* host = info[@"CN1ProxyHost"]; @@ -738,13 +893,28 @@ void cn1_debugger_start(void) { pthread_detach(t); if (g_waitForAttach) { - NSLog(@"cn1_debugger: waiting for proxy to attach and send RESUME..."); - pthread_mutex_lock(&g_attachMutex); - while (g_waitForAttach) { - pthread_cond_wait(&g_attachCond, &g_attachMutex); - } - pthread_mutex_unlock(&g_attachMutex); - NSLog(@"cn1_debugger: attach handshake complete, continuing boot"); + NSLog(@"cn1_debugger: waitForAttach=YES; installing overlay (non-blocking)"); + cn1_debugger_install_wait_overlay(); + } +} + +void cn1_debugger_run_when_ready(void (^onReady)(void)) { + if (onReady == nil) return; + if (!g_waitForAttach) { + onReady(); + return; + } + int alreadyReady = 0; + pthread_mutex_lock(&g_attachMutex); + if (g_attachReady) { + alreadyReady = 1; + } else { + g_onReadyBlock = [onReady copy]; + } + pthread_mutex_unlock(&g_attachMutex); + if (alreadyReady) { + // Proxy attached before the AppDelegate registered the callback. + dispatch_async(dispatch_get_main_queue(), onReady); } } diff --git a/maven/cn1-debug-proxy/src/main/java/com/codename1/debug/proxy/JdwpServer.java b/maven/cn1-debug-proxy/src/main/java/com/codename1/debug/proxy/JdwpServer.java index 2243ecade3..bd1e98acda 100644 --- a/maven/cn1-debug-proxy/src/main/java/com/codename1/debug/proxy/JdwpServer.java +++ b/maven/cn1-debug-proxy/src/main/java/com/codename1/debug/proxy/JdwpServer.java @@ -131,32 +131,67 @@ public void setDevice(DeviceConnection device) { } public void acceptAndServe() throws IOException { + // Loop on accept so the developer can detach and reattach the IDE + // multiple times without restarting the proxy. The device side keeps + // running between attaches and the proxy preserves its breakpoint / + // event-request state. try (ServerSocket server = new ServerSocket(port)) { System.out.println("[jdwp] listening on port " + port + " for debugger (jdb) to attach"); - socket = server.accept(); - } - socket.setTcpNoDelay(true); - in = new DataInputStream(socket.getInputStream()); - out = new DataOutputStream(socket.getOutputStream()); - System.out.println("[jdwp] debugger connected from " + socket.getRemoteSocketAddress()); - - if (!doHandshake()) { - System.err.println("[jdwp] handshake failed"); - return; - } - System.out.println("[jdwp] handshake complete"); - // If the device connected before us, fire VM_START now. - if (deviceHelloReceived) { - sendVmStart(); - } + while (true) { + socket = server.accept(); + socket.setTcpNoDelay(true); + in = new DataInputStream(socket.getInputStream()); + out = new DataOutputStream(socket.getOutputStream()); + System.out.println("[jdwp] debugger connected from " + socket.getRemoteSocketAddress()); - try { - packetLoop(); - } finally { - try { socket.close(); } catch (IOException ignore) {} + if (!doHandshake()) { + System.err.println("[jdwp] handshake failed"); + closeJdwpSession(); + continue; + } + System.out.println("[jdwp] handshake complete"); + // If the device connected before this IDE attach, fire VM_START now. + if (deviceHelloReceived) { + sendVmStart(); + } + // Auto-release the device-side waitForAttach gate after a + // short delay. The delay gives the IDE time to register + // breakpoints via EventRequest.Set so the device doesn't race + // past them when it resumes. Most JDWP debuggers (IntelliJ, + // VS Code) don't auto-send VM.Resume on attach, so without + // this nudge the app would sit on the waiting overlay forever. + Thread autoResume = new Thread(() -> { + try { Thread.sleep(500); } catch (InterruptedException ignore) {} + try { if (device != null) device.resumeAll(); } + catch (IOException ignore) {} + }, "cn1-debug-auto-resume"); + autoResume.setDaemon(true); + autoResume.start(); + + try { + packetLoop(); + } catch (IOException eof) { + // Debugger disconnected mid-session; fall through to reset. + } finally { + closeJdwpSession(); + } + System.out.println("[jdwp] debugger session ended; listening for the next attach"); + } } } + private void closeJdwpSession() { + try { if (socket != null) socket.close(); } catch (IOException ignore) {} + socket = null; + in = null; + out = null; + // Clear any pending step requests so a stale one from the previous + // attach can't fire against the new debugger. + stepRequests.clear(); + // Breakpoints stay in bpRequests so the device keeps them set; the + // next attaching IDE will see them via EventRequest semantics. + } + private void sendVmStart() { try { Buf b = new Buf(); From 7e8c0b88005e9f0a0543597b392851a923c8f0ad Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Fri, 22 May 2026 10:23:54 +0300 Subject: [PATCH 05/13] On-device-debug: object inspection + native stdout + CN1-core BP docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 ` 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. --- Ports/iOSPort/nativeSources/cn1_debugger.h | 27 ++ Ports/iOSPort/nativeSources/cn1_debugger.m | 257 ++++++++++++++++++ .../On-Device-Debugging.asciidoc | 47 +++- .../debug/proxy/DeviceConnection.java | 40 +++ .../com/codename1/debug/proxy/JdwpServer.java | 150 +++++++++- .../debug/proxy/LoggingListener.java | 8 + .../com/codename1/debug/proxy/ProxyMain.java | 3 +- .../codename1/debug/proxy/SymbolTable.java | 37 +++ .../codename1/debug/proxy/WireProtocol.java | 4 + .../tools/translator/ByteCodeClass.java | 73 ++++- .../codename1/tools/translator/Parser.java | 85 ++++++ 11 files changed, 719 insertions(+), 12 deletions(-) diff --git a/Ports/iOSPort/nativeSources/cn1_debugger.h b/Ports/iOSPort/nativeSources/cn1_debugger.h index ccee85c254..8cc1b47166 100644 --- a/Ports/iOSPort/nativeSources/cn1_debugger.h +++ b/Ports/iOSPort/nativeSources/cn1_debugger.h @@ -30,6 +30,33 @@ */ extern void cn1_debugger_start(void); +/** + * Per-class instance-field descriptor emitted by the translator (one + * static array per generated class), then registered with the debugger + * runtime by a __attribute__((constructor)) shim that the translator also + * emits. The runtime uses these to answer CMD_GET_OBJECT_FIELDS without + * any reflection / RTTI from ParparVM. + * + * offset is from the start of the object struct (i.e. offsetof). type is + * a JVM type-char ('I','J','F','D','Z','B','S','C','L' — 'L' covers + * arrays too since arrays are JAVA_OBJECT in the struct). + */ +typedef struct cn1_field_entry { + int fieldId; + int offset; + char type; + const char* name; +} cn1_field_entry; + +/** + * Translator-generated constructors call this once at process load to + * publish the class's field table to the debugger runtime. classId is + * the cn1_class_id_XXX constant. + */ +extern void cn1_debugger_register_fields(int classId, + const cn1_field_entry* table, + int count); + #ifdef __BLOCKS__ /** * Defers the VM callback until the proxy reports the IDE has attached, so diff --git a/Ports/iOSPort/nativeSources/cn1_debugger.m b/Ports/iOSPort/nativeSources/cn1_debugger.m index 340676961e..f832897bec 100644 --- a/Ports/iOSPort/nativeSources/cn1_debugger.m +++ b/Ports/iOSPort/nativeSources/cn1_debugger.m @@ -52,6 +52,7 @@ #define CMD_DISPOSE 0x0A #define CMD_GET_STRING 0x0B #define CMD_GET_OBJECT_CLASS 0x0C +#define CMD_GET_OBJECT_FIELDS 0x0D // Events (device -> proxy) #define EVT_HELLO 0x80 @@ -64,6 +65,9 @@ #define EVT_STRING_VALUE 0x87 #define EVT_REPLY_STATUS 0x88 #define EVT_OBJECT_CLASS 0x89 +#define EVT_OBJECT_FIELDS 0x8A +#define EVT_STDOUT_LINE 0x8B +#define EVT_STDERR_LINE 0x8C // java.lang.String's clazz struct is emitted by the translator; we reference // it by symbol so cn1_debugger.m doesn't depend on the generated @@ -104,6 +108,101 @@ // definition further down the file. static void cn1_debugger_fire_ready_block_if_pending(void); +/* --------------------------------------------------------------------- */ +/* stdout / stderr forwarding. dup2()'s the original FDs to a pipe, then */ +/* a reader thread chunks the pipe by '\n' and emits each completed line */ +/* as an EVT_STDOUT_LINE / EVT_STDERR_LINE so the proxy can surface them */ +/* in the IDE debug console. Partial lines buffer until a newline lands. */ +/* --------------------------------------------------------------------- */ +static int g_origStdoutFd = -1; +static int g_origStderrFd = -1; + +// Forward declaration so the capture thread can emit events; the body sits +// further down with the rest of the wire-protocol helpers. +static void sendEvent(uint8_t cmd, const void* payload, uint32_t len); + +struct stream_capture { + int readFd; // read end of the pipe; we own it + int origFd; // copy of the original FD so we can also forward to it + uint8_t evtCode; // EVT_STDOUT_LINE / EVT_STDERR_LINE +}; + +static void forwardLineToOriginal(int origFd, const uint8_t* line, size_t len) { + if (origFd < 0) return; + // Best-effort write; missing bytes here only cost us a console line. + ssize_t w = write(origFd, line, len); + (void)w; + uint8_t nl = '\n'; + w = write(origFd, &nl, 1); + (void)w; +} + +static void* streamCaptureThread(void* arg) { + struct stream_capture* cap = (struct stream_capture*)arg; + uint8_t buf[1024]; + uint8_t line[2048]; + size_t lineLen = 0; + for (;;) { + ssize_t n = read(cap->readFd, buf, sizeof(buf)); + if (n <= 0) { + if (n < 0 && (errno == EINTR || errno == EAGAIN)) continue; + break; + } + for (ssize_t i = 0; i < n; i++) { + uint8_t c = buf[i]; + if (c == '\n' || lineLen == sizeof(line)) { + // Mirror to the host console (Xcode/simulator log) so local + // debugging without an attached proxy still sees prints. + forwardLineToOriginal(cap->origFd, line, lineLen); + if (g_proxyFd >= 0) { + uint8_t* payload = (uint8_t*)malloc(4 + lineLen); + if (payload) { + uint32_t lenBE = htonl((uint32_t)lineLen); + memcpy(payload, &lenBE, 4); + if (lineLen > 0) memcpy(payload + 4, line, lineLen); + sendEvent(cap->evtCode, payload, 4 + (uint32_t)lineLen); + free(payload); + } + } + lineLen = 0; + if (c == '\n') continue; + } + line[lineLen++] = c; + } + } + return NULL; +} + +static void startStreamCapture(int* origFdSlot, FILE* stream, int fdNo, uint8_t evtCode) { + int pfd[2]; + if (pipe(pfd) != 0) return; + int savedOrig = dup(fdNo); + if (savedOrig < 0) { + close(pfd[0]); close(pfd[1]); + return; + } + *origFdSlot = savedOrig; + // Re-route the target FD so anything writing to it (printf, NSLog into + // os_log_t fallback, fprintf(stderr, ...)) goes into our pipe. + if (dup2(pfd[1], fdNo) < 0) { + close(savedOrig); close(pfd[0]); close(pfd[1]); + *origFdSlot = -1; + return; + } + close(pfd[1]); + // Line-buffer so we don't sit on a partial line. + setvbuf(stream, NULL, _IOLBF, 0); + + struct stream_capture* cap = (struct stream_capture*)malloc(sizeof(*cap)); + if (!cap) { close(pfd[0]); return; } + cap->readFd = pfd[0]; + cap->origFd = savedOrig; + cap->evtCode = evtCode; + pthread_t t; + pthread_create(&t, NULL, streamCaptureThread, cap); + pthread_detach(t); +} + /* --------------------------------------------------------------------- */ /* Breakpoint hash. Open-addressed, lock-free reads via atomic 64-bit */ /* slots. Key = (methodId << 32) | line; zero is the empty sentinel, */ @@ -148,6 +247,103 @@ static void bp_add(int methodId, int line) { NSLog(@"cn1_debugger: breakpoint table full"); } +/* --------------------------------------------------------------------- */ +/* Field-offset registry. Each translator-emitted class .m calls */ +/* cn1_debugger_register_fields from a __attribute__((constructor)), so */ +/* by the time the listener thread is alive every class has registered */ +/* its instance fields. The table is sparse (indexed by classId so */ +/* gaps are common) but classIds are dense enough that a plain array */ +/* outperforms a hash table here for the read pattern (one lookup per */ +/* CMD_GET_OBJECT_FIELDS request). */ +/* --------------------------------------------------------------------- */ + +struct cn1_field_class_entry { + const cn1_field_entry* table; + int count; +}; + +#define CN1_FIELD_REG_INITIAL_CAP 2048 +static struct cn1_field_class_entry* g_fieldsByClass = NULL; +static int g_fieldsByClassCap = 0; +static pthread_mutex_t g_fieldsRegMutex = PTHREAD_MUTEX_INITIALIZER; + +void cn1_debugger_register_fields(int classId, const cn1_field_entry* table, int count) { + if (classId < 0) return; + pthread_mutex_lock(&g_fieldsRegMutex); + if (classId >= g_fieldsByClassCap) { + int newCap = g_fieldsByClassCap == 0 ? CN1_FIELD_REG_INITIAL_CAP : g_fieldsByClassCap * 2; + while (classId >= newCap) newCap *= 2; + struct cn1_field_class_entry* n = + (struct cn1_field_class_entry*)realloc(g_fieldsByClass, + newCap * sizeof(struct cn1_field_class_entry)); + if (!n) { pthread_mutex_unlock(&g_fieldsRegMutex); return; } + memset(n + g_fieldsByClassCap, 0, + (newCap - g_fieldsByClassCap) * sizeof(struct cn1_field_class_entry)); + g_fieldsByClass = n; + g_fieldsByClassCap = newCap; + } + g_fieldsByClass[classId].table = table; + g_fieldsByClass[classId].count = count; + pthread_mutex_unlock(&g_fieldsRegMutex); +} + +/** + * Looks up the (table, count) for a classId. Walks the parent chain via + * the class's vtable since field tables only cover own + inherited + * declared on THIS class; CMD_GET_OBJECT_FIELDS resolves by classId of + * the actual runtime class (which already includes inherited fields in + * its layout-order list — see ByteCodeClass.getAllInstanceFieldsInLayoutOrder). + */ +static const cn1_field_entry* field_lookup_by_class_and_id(int classId, int fieldId, int* outCount) { + if (classId < 0 || classId >= g_fieldsByClassCap) return NULL; + const cn1_field_entry* table = g_fieldsByClass[classId].table; + int count = g_fieldsByClass[classId].count; + if (outCount) *outCount = count; + if (!table) return NULL; + for (int i = 0; i < count; i++) { + if (table[i].fieldId == fieldId) return &table[i]; + } + return NULL; +} + +/** + * Read a field value into 8 host-endian bytes plus a JVM type-char. + * Object refs become the JAVA_OBJECT pointer reinterpreted as uint64 so + * the proxy can pass them straight to JDWP as objectIDs. + */ +static int field_read_into(JAVA_OBJECT obj, const cn1_field_entry* fe, + char* outType, uint64_t* outValue) { + if (!obj || !fe) return 0; + char* base = (char*)obj + fe->offset; + *outType = fe->type; + switch (fe->type) { + case 'Z': *outValue = (uint64_t)(*(JAVA_BOOLEAN*)base & 1); return 1; + case 'B': *outValue = (uint64_t)(uint8_t)(*(JAVA_BYTE*)base); return 1; + case 'S': *outValue = (uint64_t)(uint16_t)(*(JAVA_SHORT*)base); return 1; + case 'C': *outValue = (uint64_t)(uint16_t)(*(JAVA_CHAR*)base); return 1; + case 'I': *outValue = (uint64_t)(uint32_t)(*(JAVA_INT*)base); return 1; + case 'F': { + JAVA_FLOAT f = *(JAVA_FLOAT*)base; + uint32_t bits; + memcpy(&bits, &f, 4); + *outValue = (uint64_t)bits; return 1; + } + case 'J': *outValue = (uint64_t)(*(JAVA_LONG*)base); return 1; + case 'D': { + JAVA_DOUBLE d = *(JAVA_DOUBLE*)base; + uint64_t bits; + memcpy(&bits, &d, 8); + *outValue = bits; return 1; + } + case 'L': default: { + JAVA_OBJECT v = *(JAVA_OBJECT*)base; + *outValue = (uint64_t)(uintptr_t)v; + *outType = 'L'; + return 1; + } + } +} + static void bp_clear(int methodId, int line) { // Simple zero-out. Linear probing tolerates holes only because we stop // on zero, which would terminate searches early — so on delete we shift @@ -630,6 +826,61 @@ static int handleCommand(uint8_t cmd, const uint8_t* payload, uint32_t len) { sendEvent(EVT_STRING_VALUE, utf8, (uint32_t)n); return 0; } + case CMD_GET_OBJECT_FIELDS: { + // Payload: objId(8) fieldCount(4) fieldIds[fieldCount](4 each) + if (len < 12) { + // Reply empty so the proxy doesn't hang on its synchronous wait. + uint8_t empty[4] = {0,0,0,0}; + sendEvent(EVT_OBJECT_FIELDS, empty, 4); + return 0; + } + uint32_t hi, lo; + memcpy(&hi, payload, 4); + memcpy(&lo, payload + 4, 4); + uint64_t ptr = ((uint64_t)ntohl(hi) << 32) | (uint32_t)ntohl(lo); + uint32_t countBE; + memcpy(&countBE, payload + 8, 4); + int count = (int)ntohl(countBE); + if (count < 0 || (uint32_t)(12 + count * 4) > len) { + uint8_t empty[4] = {0,0,0,0}; + sendEvent(EVT_OBJECT_FIELDS, empty, 4); + return 0; + } + JAVA_OBJECT obj = (JAVA_OBJECT)(uintptr_t)ptr; + int classId = -1; + if (obj != JAVA_NULL && obj->__codenameOneParentClsReference != NULL) { + classId = obj->__codenameOneParentClsReference->classId; + } + // Reply payload: count(4) then per-field { type(1), value(8) }. + uint32_t sz = 4 + (uint32_t)count * 9; + uint8_t* buf = (uint8_t*)malloc(sz); + if (!buf) { + uint8_t empty[4] = {0,0,0,0}; + sendEvent(EVT_OBJECT_FIELDS, empty, 4); + return 0; + } + uint32_t countOutBE = htonl((uint32_t)count); + memcpy(buf, &countOutBE, 4); + uint8_t* p = buf + 4; + for (int i = 0; i < count; i++) { + uint32_t fidBE; + memcpy(&fidBE, payload + 12 + i * 4, 4); + int fid = (int)ntohl(fidBE); + char tc = 'L'; + uint64_t val = 0; + const cn1_field_entry* fe = field_lookup_by_class_and_id(classId, fid, NULL); + if (fe && obj != JAVA_NULL) { + field_read_into(obj, fe, &tc, &val); + } else { + tc = 'L'; val = 0; + } + *p++ = (uint8_t)tc; + writeBE64(p, val); p += 8; + } + sendEvent(EVT_OBJECT_FIELDS, buf, sz); + free(buf); + return 0; + } case CMD_GET_THREADS: case CMD_SUSPEND: // Minimal viable: reply empty so the proxy doesn't hang. @@ -888,6 +1139,12 @@ void cn1_debugger_start(void) { NSNumber* wait = info[@"CN1ProxyWaitForAttach"]; g_waitForAttach = (wait && [wait boolValue]) ? 1 : 0; + // Capture stdout/stderr before any user code runs so prints during runApp + // make it to the IDE. The reader threads also mirror lines back to the + // saved original FDs so xcrun simctl log / Xcode still shows the output. + startStreamCapture(&g_origStdoutFd, stdout, STDOUT_FILENO, EVT_STDOUT_LINE); + startStreamCapture(&g_origStderrFd, stderr, STDERR_FILENO, EVT_STDERR_LINE); + pthread_t t; pthread_create(&t, NULL, listenerThreadMain, NULL); pthread_detach(t); diff --git a/docs/developer-guide/On-Device-Debugging.asciidoc b/docs/developer-guide/On-Device-Debugging.asciidoc index 9569e04588..16fca237ec 100644 --- a/docs/developer-guide/On-Device-Debugging.asciidoc +++ b/docs/developer-guide/On-Device-Debugging.asciidoc @@ -103,7 +103,8 @@ step, inspect, just like a normal remote attach. === What works today * Class loading: the IDE sees every class in your build. -* Line breakpoints in user code and in the Codename One framework. +* Line breakpoints in user code *and* in the Codename One framework + classes (`com.codename1.ui.*`, `com.codename1.io.*`, etc.). * Step into / over / out. * Stack walking — full Java stack including the Codename One framework frames above your code. @@ -111,9 +112,44 @@ step, inspect, just like a normal remote attach. byte, char, short). * Inspecting `java.lang.String` values directly. * Inspecting object references — class name and identity, plus - the ability to drill in via the IDE's variables view. + field-by-field drilldown via the IDE's variables view. The proxy + walks the ParparVM struct layout to read instance fields directly + at known offsets, so `dump this` / "Variables" shows the same + fields you'd see in a simulator debug session. +* Native device output — `System.out.println`, `Log.p`, and + `printf` / `NSLog` from native code are surfaced in the proxy's + console (and the IDE's debug console when the proxy is launched + as an IDE run configuration) prefixed with `[device]`. * Pause and resume from the IDE. +=== Pointing the IDE at the Codename One sources + +The proxy answers `Method.LineTable` and `ReferenceType.SourceFile` +for framework classes, but the IDE still needs to find the actual +`.java` files to render the source pane while stepping. Three options: + +* *Working from a clone of this repository.* Add + `CodenameOne/src` as a source root in your IDE's debug + configuration. In IntelliJ: *Run → Edit Configurations… → Remote + JVM Debug → Configuration → Source roots → +*. +* *jdb command line.* Pass the framework source dir on the + `-sourcepath`: ++ +[source,bash] +---- +jdb -attach localhost:8000 \ + -sourcepath src/main/java:/path/to/CodenameOne/CodenameOne/src +---- +* *Using the Maven artifacts.* Make sure the IDE has the + `codenameone-core-X.Y.Z-sources.jar` attached to the + `com.codenameone:codenameone-core` dependency. IntelliJ does this + automatically once you run *Maven → Reimport* with "Sources" enabled + in the Maven settings. + +Without a source path, breakpoints in framework classes still trigger +and locals / fields still read — you'll just see "Sources not found" +in the IDE editor when stepping into framework code. + === Known limitations * *Method invocation from the debugger isn't supported.* The IDE's @@ -134,6 +170,13 @@ step, inspect, just like a normal remote attach. compile with debug info by default; if a variable shows up as `v1`, `v2`, ... that's because that particular class was compiled without debug info. +* *Static field reads* fall back to "not supported". Instance field + reads are fully wired; static-field support requires an additional + per-class static table that hasn't been emitted yet. +* *NSLog on a real device* (as opposed to the simulator) may not + reach the `[device]` console. NSLog on iOS routes to `os_log` + which doesn't always mirror to `stderr`. `printf` / `fprintf` / + `Log.p` are unaffected and show up correctly. === Performance diff --git a/maven/cn1-debug-proxy/src/main/java/com/codename1/debug/proxy/DeviceConnection.java b/maven/cn1-debug-proxy/src/main/java/com/codename1/debug/proxy/DeviceConnection.java index b34b5c0c04..2175e88f2c 100644 --- a/maven/cn1-debug-proxy/src/main/java/com/codename1/debug/proxy/DeviceConnection.java +++ b/maven/cn1-debug-proxy/src/main/java/com/codename1/debug/proxy/DeviceConnection.java @@ -41,7 +41,10 @@ public interface DeviceListener { void onVmDeath(); void onStringValue(String value); void onObjectClass(int classId); + void onObjectFields(byte[] typeCodes, long[] values); void onReplyStatus(); + void onStdoutLine(String line); + void onStderrLine(String line); void onUnknownEvent(int code, byte[] payload); void onDisconnected(); } @@ -157,6 +160,33 @@ private void dispatch(int code, byte[] p) { listener.onObjectClass(cid); return; } + case WireProtocol.EVT_OBJECT_FIELDS: { + if (p.length < 4) { listener.onUnknownEvent(code, p); return; } + int n = readInt(p, 0); + byte[] types = new byte[n]; + long[] values = new long[n]; + int off = 4; + for (int i = 0; i < n; i++) { + types[i] = p[off]; off += 1; + values[i] = readLong(p, off); off += 8; + } + listener.onObjectFields(types, values); + return; + } + case WireProtocol.EVT_STDOUT_LINE: + case WireProtocol.EVT_STDERR_LINE: { + // Payload: 4-byte big-endian length, then UTF-8 line bytes. + if (p.length < 4) { listener.onUnknownEvent(code, p); return; } + int len = readInt(p, 0); + if (len < 0 || 4 + len > p.length) { listener.onUnknownEvent(code, p); return; } + String s = new String(p, 4, len, java.nio.charset.StandardCharsets.UTF_8); + if (code == WireProtocol.EVT_STDOUT_LINE) { + listener.onStdoutLine(s); + } else { + listener.onStderrLine(s); + } + return; + } case WireProtocol.EVT_REPLY_STATUS: listener.onReplyStatus(); return; @@ -236,6 +266,16 @@ public void getString(long objPtr) throws IOException { sendCommand(WireProtocol.CMD_GET_STRING, p); } + public void getObjectFields(long objPtr, int[] fieldIds) throws IOException { + byte[] p = new byte[8 + 4 + fieldIds.length * 4]; + writeLong(p, 0, objPtr); + writeInt(p, 8, fieldIds.length); + for (int i = 0; i < fieldIds.length; i++) { + writeInt(p, 12 + i * 4, fieldIds[i]); + } + sendCommand(WireProtocol.CMD_GET_OBJECT_FIELDS, p); + } + @Override public void close() { if (closed.compareAndSet(false, true)) { diff --git a/maven/cn1-debug-proxy/src/main/java/com/codename1/debug/proxy/JdwpServer.java b/maven/cn1-debug-proxy/src/main/java/com/codename1/debug/proxy/JdwpServer.java index bd1e98acda..47ead2f8e4 100644 --- a/maven/cn1-debug-proxy/src/main/java/com/codename1/debug/proxy/JdwpServer.java +++ b/maven/cn1-debug-proxy/src/main/java/com/codename1/debug/proxy/JdwpServer.java @@ -438,10 +438,43 @@ private void handleRefType(int id, int cmd, byte[] p) throws IOException { Buf b = new Buf(); b.writeInt(0x0001); writeReply(id, 0, b.bytes()); return; } case 4: { // Fields - Buf b = new Buf(); b.writeInt(0); writeReply(id, 0, b.bytes()); return; + Buf b = new Buf(); + if (c == null) { b.writeInt(0); writeReply(id, 0, b.bytes()); return; } + // Only fields declared on this class, not inherited — JDWP + // semantics. We filter from c.instanceFields (which may + // include inherited entries due to the translator listing + // them under the storing class for value-read efficiency). + java.util.List declared = new java.util.ArrayList<>(); + for (SymbolTable.FieldInfo fi : c.instanceFields) { + if (fi.classId == c.classId) declared.add(fi); + } + b.writeInt(declared.size()); + for (SymbolTable.FieldInfo fi : declared) { + b.writeLong(toJdwpRef(fi.fieldId)); + b.writeString(fi.name); + b.writeString(fi.descriptor); + b.writeInt(fi.accessFlags); + } + writeReply(id, 0, b.bytes()); + return; } - case 14: { // FieldsWithGeneric — stubbed empty too - Buf b = new Buf(); b.writeInt(0); writeReply(id, 0, b.bytes()); return; + case 14: { // FieldsWithGeneric + Buf b = new Buf(); + if (c == null) { b.writeInt(0); writeReply(id, 0, b.bytes()); return; } + java.util.List declared = new java.util.ArrayList<>(); + for (SymbolTable.FieldInfo fi : c.instanceFields) { + if (fi.classId == c.classId) declared.add(fi); + } + b.writeInt(declared.size()); + for (SymbolTable.FieldInfo fi : declared) { + b.writeLong(toJdwpRef(fi.fieldId)); + b.writeString(fi.name); + b.writeString(fi.descriptor); + b.writeString(""); // generic signature + b.writeInt(fi.accessFlags); + } + writeReply(id, 0, b.bytes()); + return; } case 5: { // Methods Buf b = new Buf(); @@ -719,7 +752,38 @@ private void handleStackFrame(int id, int cmd, byte[] p) throws IOException { return; } case 3: { // ThisObject - Buf b = new Buf(); b.writeByte('L'); b.writeLong(0); writeReply(id, 0, b.bytes()); return; + // JDWP "ThisObject" returns the receiver of the frame's + // method, or null for statics. We don't track static-ness + // explicitly, so we ask the device for slot 0 of this frame: + // for instance methods the JVM places `this` there on entry, + // for statics it stays 0/null which is the correct JDWP reply. + int[] devSlots; byte[] devTypes; long[] devValues; + synchronized (localsLock) { + pendingLocals = true; + lastLocalsSlots = null; lastLocalsTypes = null; lastLocalsValues = null; + try { device.getLocals(tid, frameIdx); } + catch (IOException io) { + Buf b = new Buf(); b.writeByte('L'); b.writeLong(0); writeReply(id, 0, b.bytes()); return; + } + long deadline = System.currentTimeMillis() + 2000; + while (pendingLocals && System.currentTimeMillis() < deadline) { + try { localsLock.wait(deadline - System.currentTimeMillis()); } + catch (InterruptedException ie) { break; } + } + devSlots = lastLocalsSlots; devTypes = lastLocalsTypes; devValues = lastLocalsValues; + } + Buf b = new Buf(); + b.writeByte('L'); + long ref = 0; + if (devSlots != null && devTypes != null && devValues != null) { + int i = findSlot(devSlots, 0); + if (i >= 0 && ((char) devTypes[i] == 'L' || (char) devTypes[i] == '[')) { + ref = devValues[i]; + } + } + b.writeLong(ref); + writeReply(id, 0, b.bytes()); + return; } default: writeReply(id, 100, empty()); @@ -852,13 +916,39 @@ private void handleObject(int id, int cmd, byte[] p) throws IOException { writeReply(id, 0, b.bytes()); return; } - case 2: { // GetValues — instance field reads. Stub: empty. + case 2: { // GetValues — instance field reads int count = readInt(p, 8); + int[] fieldIds = new int[count]; + for (int i = 0; i < count; i++) { + long jdwpFid = readLong(p, 12 + i * 8); + fieldIds[i] = fromJdwpRef(jdwpFid); + } Buf b = new Buf(); b.writeInt(count); + if (count == 0) { writeReply(id, 0, b.bytes()); return; } + boolean ok = blockingGetObjectFields(objectId, fieldIds); + byte[] types = lastObjectFieldsTypes; + long[] values = lastObjectFieldsValues; + if (!ok || types == null || values == null || types.length != count) { + // Fall back to writing nulls for each requested field so jdb + // still gets a well-formed reply. + for (int i = 0; i < count; i++) { b.writeByte('L'); b.writeLong(0); } + writeReply(id, 0, b.bytes()); + return; + } for (int i = 0; i < count; i++) { - b.writeByte('L'); - b.writeLong(0); + byte tc = types[i]; + long v = values[i]; + b.writeByte(tc); + switch ((char) tc) { + case 'Z': b.writeByte((int) v & 1); break; + case 'B': b.writeByte((int) v & 0xff); break; + case 'S': case 'C': b.writeShort((int) v & 0xffff); break; + case 'I': case 'F': b.writeInt((int) v); break; + case 'J': case 'D': b.writeLong(v); break; + case 'L': case '[': b.writeLong(v); break; + default: b.writeLong(v); + } } writeReply(id, 0, b.bytes()); return; @@ -907,6 +997,34 @@ private int blockingGetObjectClass(long objectId) { } } + private final Object objectFieldsLock = new Object(); + private boolean pendingObjectFields = false; + private byte[] lastObjectFieldsTypes = null; + private long[] lastObjectFieldsValues = null; + + /** + * Blocking call to the device's CMD_GET_OBJECT_FIELDS. Returns a parallel + * pair of arrays (type-code, raw value) or null on timeout. The proxy + * orders the request fields the way the IDE asked, so the response + * preserves the same ordering — no field-id round-trip needed here. + */ + private boolean blockingGetObjectFields(long objectId, int[] fieldIds) { + if (device == null) return false; + synchronized (objectFieldsLock) { + pendingObjectFields = true; + lastObjectFieldsTypes = null; + lastObjectFieldsValues = null; + try { device.getObjectFields(objectId, fieldIds); } + catch (IOException io) { return false; } + long deadline = System.currentTimeMillis() + 2000; + while (pendingObjectFields && System.currentTimeMillis() < deadline) { + try { objectFieldsLock.wait(deadline - System.currentTimeMillis()); } + catch (InterruptedException ie) { break; } + } + return lastObjectFieldsTypes != null; + } + } + private final Object stringLock = new Object(); private boolean pendingString = false; private String lastString = null; @@ -1075,7 +1193,25 @@ private void fetchStackForThread(long tid) { objectClassLock.notifyAll(); } } + @Override public void onObjectFields(byte[] typeCodes, long[] values) { + synchronized (objectFieldsLock) { + lastObjectFieldsTypes = typeCodes; + lastObjectFieldsValues = values; + pendingObjectFields = false; + objectFieldsLock.notifyAll(); + } + } @Override public void onReplyStatus() {} + @Override public void onStdoutLine(String line) { + // Surface device prints in whatever console the proxy is running in + // (a terminal during local dev, or the IDE's "Run" console when the + // proxy is launched as an IDE run configuration). The prefix keeps + // proxy-internal noise distinguishable from app output. + System.out.println("[device] " + line); + } + @Override public void onStderrLine(String line) { + System.err.println("[device] " + line); + } @Override public void onUnknownEvent(int code, byte[] payload) { System.out.println("[jdwp] unknown device event code 0x" + Integer.toHexString(code)); } diff --git a/maven/cn1-debug-proxy/src/main/java/com/codename1/debug/proxy/LoggingListener.java b/maven/cn1-debug-proxy/src/main/java/com/codename1/debug/proxy/LoggingListener.java index 64a3add69d..32d29e77f0 100644 --- a/maven/cn1-debug-proxy/src/main/java/com/codename1/debug/proxy/LoggingListener.java +++ b/maven/cn1-debug-proxy/src/main/java/com/codename1/debug/proxy/LoggingListener.java @@ -55,7 +55,15 @@ public LoggingListener(SymbolTable symbols) { @Override public void onVmDeath() { System.out.println("[event] VM_DEATH"); } @Override public void onStringValue(String value) { System.out.println("[event] STRING_VALUE=" + value); } @Override public void onObjectClass(int classId) { System.out.println("[event] OBJECT_CLASS=" + classId); } + @Override public void onObjectFields(byte[] typeCodes, long[] values) { + System.out.println("[event] OBJECT_FIELDS count=" + typeCodes.length); + for (int i = 0; i < typeCodes.length; i++) { + System.out.println(" #" + i + " type='" + (char) typeCodes[i] + "' value=" + formatValue(typeCodes[i], values[i])); + } + } @Override public void onReplyStatus() { System.out.println("[event] REPLY_STATUS"); } + @Override public void onStdoutLine(String line) { System.out.println("[device] " + line); } + @Override public void onStderrLine(String line) { System.err.println("[device] " + line); } @Override public void onUnknownEvent(int code, byte[] payload) { System.out.println("[event] UNKNOWN code=0x" + Integer.toHexString(code) + " payload=" + Arrays.toString(payload)); } diff --git a/maven/cn1-debug-proxy/src/main/java/com/codename1/debug/proxy/ProxyMain.java b/maven/cn1-debug-proxy/src/main/java/com/codename1/debug/proxy/ProxyMain.java index cf85db4747..87ccb9146b 100644 --- a/maven/cn1-debug-proxy/src/main/java/com/codename1/debug/proxy/ProxyMain.java +++ b/maven/cn1-debug-proxy/src/main/java/com/codename1/debug/proxy/ProxyMain.java @@ -58,7 +58,8 @@ public static void main(String[] args) throws Exception { SymbolTable symbols = SymbolTable.load(Paths.get(symbolsPath)); System.out.println("Loaded " + symbols.allClasses().size() + " classes, " - + symbols.allMethods().size() + " methods from " + symbolsPath); + + symbols.allMethods().size() + " methods, " + + symbols.fieldCount() + " fields from " + symbolsPath); final DeviceConnection.DeviceListener listener; final JdwpServer jdwpServer; diff --git a/maven/cn1-debug-proxy/src/main/java/com/codename1/debug/proxy/SymbolTable.java b/maven/cn1-debug-proxy/src/main/java/com/codename1/debug/proxy/SymbolTable.java index 634da02ac7..443180ac38 100644 --- a/maven/cn1-debug-proxy/src/main/java/com/codename1/debug/proxy/SymbolTable.java +++ b/maven/cn1-debug-proxy/src/main/java/com/codename1/debug/proxy/SymbolTable.java @@ -36,6 +36,13 @@ public static final class ClassInfo { public final String name; // e.g. "java_lang_String" (translator convention) public final String sourceFile; public final List methods = new ArrayList<>(); + /** + * Instance fields physically stored in this class's struct (i.e. including + * those inherited from parents). The translator emits all of them under + * the storing class so the proxy can satisfy ObjectReference.GetValues + * without walking a JDWP ReferenceType hierarchy. + */ + public final List instanceFields = new ArrayList<>(); ClassInfo(int classId, String name, String sourceFile) { this.classId = classId; @@ -49,6 +56,22 @@ public String jvmSignature() { } } + public static final class FieldInfo { + public final int fieldId; + public final int classId; // declaring class, not necessarily the holding class + public final String name; + public final String descriptor; // JVM-style: "I", "Ljava/lang/String;", "[I", ... + public final int accessFlags; // JDWP modifier bits + + FieldInfo(int fieldId, int classId, String name, String descriptor, int accessFlags) { + this.fieldId = fieldId; + this.classId = classId; + this.name = name; + this.descriptor = descriptor; + this.accessFlags = accessFlags; + } + } + public static final class LocalVarInfo { public final int slot; public final String name; @@ -107,6 +130,7 @@ public int argSlots(boolean isStatic) { private final Map classesById = new HashMap<>(); private final Map methodsById = new HashMap<>(); + private final Map fieldsById = new HashMap<>(); private final Map classesByJvmSig = new HashMap<>(); private final Map classesBySourceFile = new HashMap<>(); @@ -158,6 +182,17 @@ public static SymbolTable load(Path file) throws IOException { if (m != null) m.locals.add(new LocalVarInfo(slot, parts[3], parts[4])); break; } + case "field": { + if (parts.length < 6) continue; + int classId = Integer.parseInt(parts[1]); + int fid = Integer.parseInt(parts[2]); + int access = Integer.parseInt(parts[5]); + FieldInfo fi = new FieldInfo(fid, classId, parts[3], parts[4], access); + t.fieldsById.put(fid, fi); + ClassInfo c = t.classesById.get(classId); + if (c != null) c.instanceFields.add(fi); + break; + } default: // unknown directives are ignored to allow forward-compat } @@ -168,6 +203,8 @@ public static SymbolTable load(Path file) throws IOException { public ClassInfo classById(int id) { return classesById.get(id); } public MethodInfo methodById(int id) { return methodsById.get(id); } + public FieldInfo fieldById(int id) { return fieldsById.get(id); } + public int fieldCount() { return fieldsById.size(); } public ClassInfo classByJvmSignature(String sig) { return classesByJvmSig.get(sig); } public ClassInfo classBySourceFile(String name) { return classesBySourceFile.get(name); } public java.util.Collection allClasses() { return classesById.values(); } diff --git a/maven/cn1-debug-proxy/src/main/java/com/codename1/debug/proxy/WireProtocol.java b/maven/cn1-debug-proxy/src/main/java/com/codename1/debug/proxy/WireProtocol.java index 608e668116..ed69cabb9c 100644 --- a/maven/cn1-debug-proxy/src/main/java/com/codename1/debug/proxy/WireProtocol.java +++ b/maven/cn1-debug-proxy/src/main/java/com/codename1/debug/proxy/WireProtocol.java @@ -34,6 +34,7 @@ private WireProtocol() {} public static final int CMD_DISPOSE = 0x0A; public static final int CMD_GET_STRING = 0x0B; public static final int CMD_GET_OBJECT_CLASS = 0x0C; + public static final int CMD_GET_OBJECT_FIELDS = 0x0D; public static final int EVT_HELLO = 0x80; public static final int EVT_THREAD_LIST = 0x81; @@ -45,6 +46,9 @@ private WireProtocol() {} public static final int EVT_STRING_VALUE = 0x87; public static final int EVT_REPLY_STATUS = 0x88; public static final int EVT_OBJECT_CLASS = 0x89; + public static final int EVT_OBJECT_FIELDS = 0x8A; + public static final int EVT_STDOUT_LINE = 0x8B; + public static final int EVT_STDERR_LINE = 0x8C; public static final int STEP_INTO = 0; public static final int STEP_OVER = 1; diff --git a/vm/ByteCodeTranslator/src/com/codename1/tools/translator/ByteCodeClass.java b/vm/ByteCodeTranslator/src/com/codename1/tools/translator/ByteCodeClass.java index da5a429592..4c01838478 100644 --- a/vm/ByteCodeTranslator/src/com/codename1/tools/translator/ByteCodeClass.java +++ b/vm/ByteCodeTranslator/src/com/codename1/tools/translator/ByteCodeClass.java @@ -1257,12 +1257,57 @@ public String generateCCode(List allClasses) { b.append(");\n"); b.append("__").append(clsName).append("_LOADED__=1;\n"); - + b.append("}\n\n"); - + + // On-device-debug: emit the instance-field offset table for this + // class. Wrapped in CN1_ON_DEVICE_DEBUG so release builds don't pay + // the data or registration cost. + if (BytecodeMethod.isOnDeviceDebug()) { + appendOnDeviceDebugFieldTable(b); + } + return b.toString(); } + private void appendOnDeviceDebugFieldTable(StringBuilder b) { + // Inherit-through layout-order list: parents first, this class last. + // Matches addFields() so offsetof() lines up with the actual struct. + List instance = getAllInstanceFieldsInLayoutOrder(); + // Drop any field whose declaring class isn't itself in the + // translation unit. We can't take offsetof of a field whose struct + // we don't have, but this should never happen — translator pulls in + // parents transitively. + b.append("\n#ifdef CN1_ON_DEVICE_DEBUG\n"); + b.append("#import \"cn1_debugger.h\"\n"); + b.append("static const cn1_field_entry __cn1_dbg_fields_").append(clsName).append("[] = {\n"); + for (ByteCodeField bf : instance) { + String declCls = bf.getClsName().replace('/', '_').replace('$', '_'); + int fid = Parser.getOrAssignFieldId(declCls, bf.getFieldName()); + char tc = onDeviceDebugTypeCharFor(bf); + b.append(" { ").append(fid) + .append(", (int)offsetof(struct obj__").append(clsName) + .append(", ").append(declCls).append("_").append(bf.getFieldName()) + .append("), '").append(tc).append("', \"") + .append(bf.getFieldName()).append("\" },\n"); + } + b.append("};\n"); + b.append("__attribute__((constructor)) static void __cn1_dbg_register_").append(clsName).append("(void) {\n"); + b.append(" cn1_debugger_register_fields(cn1_class_id_").append(clsName).append(",\n"); + b.append(" __cn1_dbg_fields_").append(clsName).append(",\n"); + b.append(" (int)(sizeof(__cn1_dbg_fields_").append(clsName).append(") / sizeof(cn1_field_entry)));\n"); + b.append("}\n"); + b.append("#endif // CN1_ON_DEVICE_DEBUG\n"); + } + + private static char onDeviceDebugTypeCharFor(ByteCodeField bf) { + // Object and arrays — both stored as JAVA_OBJECT in the C struct. + if (bf.isObjectType()) return 'L'; + String d = bf.getRuntimeDescriptor(); + if (d != null && d.length() == 1) return d.charAt(0); + return 'L'; + } + private boolean doesImplement(ByteCodeClass interfaceObj) { if(baseInterfacesObject != null) { if(baseInterfacesObject.contains(interfaceObj)) { @@ -1981,6 +2026,30 @@ public List getFields() { return fields; } + /** + * Walks the inheritance chain and collects every instance field this class + * physically stores in its C struct (in declaration order, parents first + * to match {@link #addFields}). Used by the on-device-debug sidecar so the + * proxy can ask the device for inherited fields by their declaring-class + * fieldId without a JDWP-level type walk. + */ + public List getAllInstanceFieldsInLayoutOrder() { + List out = new ArrayList<>(); + collectInstanceFieldsInLayoutOrder(out); + return out; + } + + private void collectInstanceFieldsInLayoutOrder(List out) { + if (baseClassObject != null) { + baseClassObject.collectInstanceFieldsInLayoutOrder(out); + } + for (ByteCodeField bf : fields) { + if (!bf.isStaticField()) { + out.add(bf); + } + } + } + /** * @return the isInterface */ diff --git a/vm/ByteCodeTranslator/src/com/codename1/tools/translator/Parser.java b/vm/ByteCodeTranslator/src/com/codename1/tools/translator/Parser.java index 4b4675207a..36de5de15a 100644 --- a/vm/ByteCodeTranslator/src/com/codename1/tools/translator/Parser.java +++ b/vm/ByteCodeTranslator/src/com/codename1/tools/translator/Parser.java @@ -106,6 +106,70 @@ public static int getClassOffset(String name) { return bc == null ? -1 : bc.getClassOffset(); } + /** + * On-device-debug field-id allocator. Maps a (declaring-class, field-name) + * pair to a stable int the device and proxy both use to address an + * instance field. Field ids start at 1 — 0 is reserved as a "no-field" + * sentinel so JDWP code can use 0 to mean "skip". + * + * IDs are persistent only within a single translator invocation; the + * sidecar carries them so the proxy doesn't need to recompute the same + * mapping. + */ + private static final java.util.LinkedHashMap fieldIdByKey = new java.util.LinkedHashMap<>(); + private static int nextFieldId = 1; + + public static int getOrAssignFieldId(String declClassMangled, String fieldName) { + String key = declClassMangled + "." + fieldName; + Integer id = fieldIdByKey.get(key); + if (id != null) return id; + id = nextFieldId++; + fieldIdByKey.put(key, id); + return id; + } + + /** + * Returns the JVM-style descriptor for a ByteCodeField — "I" for int, + * "Lcom/example/Foo;" for object, "[I" for int[], etc. The translator + * normally exposes underscore-mangled type names; this helper converts + * to JVM form for JDWP wire compatibility. + */ + public static String jvmDescriptorOf(ByteCodeField bf) { + StringBuilder sb = new StringBuilder(); + String rd = bf.getRuntimeDescriptor(); + // getRuntimeDescriptor returns either a JVM single-char (I/J/...) + // or an underscore-mangled object type, possibly followed by "[]" + // repeats for array dimensions. + int arrayDims = 0; + while (rd != null && rd.endsWith("[]")) { + arrayDims++; + rd = rd.substring(0, rd.length() - 2); + } + for (int i = 0; i < arrayDims; i++) sb.append('['); + if (rd != null && rd.length() == 1 && "ZBSCIJFD".indexOf(rd.charAt(0)) >= 0) { + sb.append(rd); + } else if (rd != null && !rd.isEmpty()) { + sb.append('L').append(rd.replace('_', '/')).append(';'); + } else { + sb.append("Ljava/lang/Object;"); + } + return sb.toString(); + } + + /** + * Returns a JDWP-style modifier bitmask (PUBLIC=1, PRIVATE=2, STATIC=8, + * FINAL=16, VOLATILE=64, TRANSIENT=128). We don't track protected/transient + * — fields are reported as public unless flagged private. + */ + public static int jdwpAccessFlagsOf(ByteCodeField bf) { + int f = 0; + if (bf.isPrivate()) f |= 0x0002; else f |= 0x0001; + if (bf.isStaticField()) f |= 0x0008; + if (bf.isFinal()) f |= 0x0010; + if (bf.isVolatile()) f |= 0x0040; + return f; + } + /** * Writes the on-device-debug symbol sidecar (cn1-symbols.txt) to the * output directory. Format is line-based ASCII for trivial parsing @@ -129,6 +193,27 @@ private static void writeSymbolSidecar(File outputDirectory) throws IOException } w.write("class\t" + bc.getClassOffset() + "\t" + bc.getClsName() + "\t" + src + "\n"); } + // Emit instance-field metadata so the proxy can answer JDWP + // ClassType.Fields / FieldsWithGeneric without a device round-trip, + // and so ObjectReference.GetValues knows what (type, declaring class) + // each fieldId resolves to. We list inherited fields under each + // class that physically stores them — JDWP expects a class's + // Fields response to include only its own declarations, but + // listing inherited fields too keeps single-table lookup cheap on + // the proxy side; the proxy filters to "declared here" itself. + for (ByteCodeClass bc : classes) { + int classId = bc.getClassOffset(); + for (ByteCodeField bf : bc.getFields()) { + if (bf.isStaticField()) continue; + int fid = getOrAssignFieldId(bc.getClsName(), bf.getFieldName()); + String desc = jvmDescriptorOf(bf); + int access = jdwpAccessFlagsOf(bf); + w.write("field\t" + classId + "\t" + fid + + "\t" + bf.getFieldName() + + "\t" + desc + + "\t" + access + "\n"); + } + } for (ByteCodeClass bc : classes) { int classId = bc.getClassOffset(); for (BytecodeMethod m : bc.getMethods()) { From 08a9a8df5e67d179f7dffe8f85cef8ea2ebd335a Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Fri, 22 May 2026 12:09:18 +0300 Subject: [PATCH 06/13] Fix EventRequest.Set walking past payload + IntelliJ console docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../On-Device-Debugging.asciidoc | 23 +++++++ .../com/codename1/debug/proxy/JdwpServer.java | 68 +++++++++++++++---- 2 files changed, 79 insertions(+), 12 deletions(-) diff --git a/docs/developer-guide/On-Device-Debugging.asciidoc b/docs/developer-guide/On-Device-Debugging.asciidoc index 16fca237ec..d47ad5fd2f 100644 --- a/docs/developer-guide/On-Device-Debugging.asciidoc +++ b/docs/developer-guide/On-Device-Debugging.asciidoc @@ -100,6 +100,29 @@ When the app starts running on the device, it'll connect to the proxy and the IDE will see a fresh suspended VM. Set breakpoints, step, inspect, just like a normal remote attach. +==== Surfacing device output in the IDE console + +The proxy prints every `[device]` line to its own stdout. An IDE +remote-attach configuration sees the JDWP socket but not the +proxy's console, so device prints don't appear in the *Debug* +window by default. + +Two workarounds: + +* *Tail the proxy log in a terminal.* If the proxy was started + via `mvn cn1:ios-on-device-debugging` you can run + `tail -f target/cn1-debug-proxy.log` in another terminal. +* *Launch the proxy as an additional IDE Run config.* In IntelliJ + IDEA: *Run* → *Edit Configurations…* → *+* → *Application*. Set + the main class to `com.codename1.debug.proxy.ProxyMain`, fill in + *Program arguments* with `--symbols=path/to/cn1-symbols.txt` + `--device-port=55333 --jdwp-port=8000`, and put + `com.codenameone:cn1-debug-proxy:8.0-SNAPSHOT` on the classpath + (Modules → Dependencies → +). The proxy's stdout now lands in + IntelliJ's *Run* window, which is the easiest way to follow + `[device]` lines alongside the JDWP attach session — group both + configs into a *Compound* run config so they start together. + === What works today * Class loading: the IDE sees every class in your build. diff --git a/maven/cn1-debug-proxy/src/main/java/com/codename1/debug/proxy/JdwpServer.java b/maven/cn1-debug-proxy/src/main/java/com/codename1/debug/proxy/JdwpServer.java index 47ead2f8e4..d854e0aaf6 100644 --- a/maven/cn1-debug-proxy/src/main/java/com/codename1/debug/proxy/JdwpServer.java +++ b/maven/cn1-debug-proxy/src/main/java/com/codename1/debug/proxy/JdwpServer.java @@ -260,6 +260,10 @@ private void writeReply(int id, int errorCode, byte[] data) throws IOException { private void writeEventCommand(byte[] data) throws IOException { synchronized (writeLock) { + // If the IDE has detached between us deciding to emit an event + // and actually serializing it, out goes null. Swallow rather + // than NPE'ing the listener thread. + if (out == null) return; int len = 11 + data.length; out.writeInt(len); out.writeInt(nextRequestId.incrementAndGet()); @@ -389,6 +393,7 @@ private void handleVM(int id, int cmd, byte[] p) throws IOException { return; } case 9: { // Resume + System.out.println("[jdwp] VM.Resume"); if (device != null) try { device.resumeAll(); } catch (IOException ignore) {} writeReply(id, 0, empty()); return; @@ -625,6 +630,7 @@ private void handleThread(int id, int cmd, byte[] p) throws IOException { writeReply(id, 0, empty()); return; } case 3: { // Resume + System.out.println("[jdwp] Thread.Resume tid=" + tid); if (device != null) try { device.resume(tid); } catch (IOException ignore) {} writeReply(id, 0, empty()); return; } @@ -808,43 +814,77 @@ private void handleEventRequest(int id, int cmd, byte[] p) throws IOException { long typeID = 0, methodID = 0, codeIndex = 0; long stepThread = 0; int stepDepth = 0; - for (int i = 0; i < modCount; i++) { + boolean badModifier = false; + for (int i = 0; i < modCount && !badModifier; i++) { + if (off >= p.length) { badModifier = true; break; } int modKind = p[off] & 0xff; off += 1; + // ClassOnly (3) is a refTypeID (8 bytes), not a string — + // ClassMatch (4) and ClassExclude (5) are strings. Splitting + // the case body keeps the dispatch tidy. switch (modKind) { case 7: { // LocationOnly - int typeTag = p[off] & 0xff; off += 1; + // Location: typeTag(1) + classID(8) + methodID(8) + codeIndex(8) = 25 + if (off + 25 > p.length) { badModifier = true; break; } + /* typeTag */ off += 1; typeID = readLong(p, off); off += 8; methodID = readLong(p, off); off += 8; codeIndex = readLong(p, off); off += 8; break; } case 1: { // Count + if (off + 4 > p.length) { badModifier = true; break; } off += 4; break; } case 2: { // ThreadOnly + if (off + 8 > p.length) { badModifier = true; break; } off += 8; break; } - case 3: case 4: case 5: { // ClassOnly, ClassMatch, ClassExclude - off = skipString(p, off + (modKind == 3 ? 8 : 0)); + case 3: { // ClassOnly (refTypeID) + if (off + 8 > p.length) { badModifier = true; break; } + off += 8; break; + } + case 4: case 5: { // ClassMatch, ClassExclude (string) + if (off + 4 > p.length) { badModifier = true; break; } + int slen = readInt(p, off); + if (slen < 0 || off + 4 + slen > p.length) { badModifier = true; break; } + off += 4 + slen; break; } - case 8: { // ExceptionOnly - off += 8 + 1 + 1; break; + case 8: { // ExceptionOnly: refTypeID + caught(byte) + uncaught(byte) + if (off + 10 > p.length) { badModifier = true; break; } + off += 10; break; + } + case 9: { // FieldOnly: refTypeID + fieldID + if (off + 16 > p.length) { badModifier = true; break; } + off += 16; break; } - case 10: { // Step modifier (threadID, size, depth) + case 10: { // Step modifier: threadID(8) + size(4) + depth(4) = 16 + if (off + 16 > p.length) { badModifier = true; break; } stepThread = readLong(p, off); off += 8; - // size: 0=MIN(instruction), 1=LINE — we only do LINE - off += 4; + /* size */ off += 4; stepDepth = readInt(p, off); off += 4; break; } case 11: { // InstanceOnly + if (off + 8 > p.length) { badModifier = true; break; } off += 8; break; } + case 12: { // SourceNameMatch (string) — JDWP 1.6+ + if (off + 4 > p.length) { badModifier = true; break; } + int slen = readInt(p, off); + if (slen < 0 || off + 4 + slen > p.length) { badModifier = true; break; } + off += 4 + slen; + break; + } default: - // Best-effort skip; many modifier kinds are variable-width - // but jdb mostly uses the ones above. - off = p.length; break; + // Unknown modifier type. We don't know its width + // so we have to bail rather than guessing — guessing + // walks the read pointer past the buffer and + // crashes the dispatcher. + System.out.println("[jdwp] EventRequest.Set: unknown modKind=" + modKind + + " — ignoring remaining " + (modCount - i - 1) + " modifiers"); + badModifier = true; + break; } } int rid = nextRequestId.incrementAndGet(); @@ -865,6 +905,9 @@ private void handleEventRequest(int id, int cmd, byte[] p) throws IOException { // depth: 0=INTO, 1=OVER, 2=OUT — same numeric values as // the wire protocol's STEP_INTO/OVER/OUT, so no mapping. stepRequests.put(stepThread, rid); + System.out.println("[jdwp] STEP request tid=" + stepThread + + " depth=" + stepDepth + + " (0=INTO 1=OVER 2=OUT) rid=" + rid); if (device != null && deviceHelloReceived) try { device.step(stepThread, stepDepth); } catch (IOException io) { /* ignore */ } @@ -1129,6 +1172,7 @@ private void fetchStackForThread(long tid) { @Override public void onStepComplete(long threadId, int methodId, int line) { Integer rid = stepRequests.remove(threadId); + System.out.println("[jdwp] STEP_COMPLETE tid=" + threadId + " methodId=" + methodId + " line=" + line + " rid=" + rid); try { SymbolTable.MethodInfo m = symbols.methodById(methodId); int classId = m != null ? m.classId : 0; From 36531d187196d115c320d105257b0de07f9c604e Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Fri, 22 May 2026 13:13:19 +0300 Subject: [PATCH 07/13] On-device debug: method invocation (Class/ObjectReference InvokeMethod) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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_() / () 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 "". - 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. --- Ports/iOSPort/nativeSources/cn1_debugger.h | 51 +++++ Ports/iOSPort/nativeSources/cn1_debugger.m | 176 ++++++++++++++++++ .../On-Device-Debugging.asciidoc | 29 +-- .../debug/proxy/DeviceConnection.java | 32 ++++ .../com/codename1/debug/proxy/JdwpServer.java | 169 ++++++++++++++++- .../debug/proxy/LoggingListener.java | 3 + .../codename1/debug/proxy/SymbolTable.java | 13 +- .../codename1/debug/proxy/WireProtocol.java | 2 + .../tools/translator/ByteCodeClass.java | 66 +++++++ .../tools/translator/BytecodeMethod.java | 128 +++++++++++++ .../codename1/tools/translator/Parser.java | 29 ++- 11 files changed, 679 insertions(+), 19 deletions(-) diff --git a/Ports/iOSPort/nativeSources/cn1_debugger.h b/Ports/iOSPort/nativeSources/cn1_debugger.h index 8cc1b47166..af33375eed 100644 --- a/Ports/iOSPort/nativeSources/cn1_debugger.h +++ b/Ports/iOSPort/nativeSources/cn1_debugger.h @@ -57,6 +57,57 @@ extern void cn1_debugger_register_fields(int classId, const cn1_field_entry* table, int count); +/* --- Method invocation -------------------------------------------------- */ + +/** + * Argument or scratch slot for a debugger-driven method invocation. All + * args travel as a flat array of these; the thunk reads the right field + * for each declared parameter. Floats/doubles round-trip through the bit + * width of their integer counterparts since debug clients pass them as + * raw 32/64-bit values. + */ +typedef union cn1_invoke_arg { + JAVA_INT i; + JAVA_LONG j; + JAVA_FLOAT f; + JAVA_DOUBLE d; + JAVA_OBJECT o; +} cn1_invoke_arg; + +/** + * Result of a debugger-driven method invocation. {@code type} is a JVM + * type-char ('V', 'I', 'J', 'F', 'D', 'L', 'Z', 'B', 'S', 'C') or 'X' + * if the call threw — in which case {@code value.o} carries the + * Throwable. + */ +typedef struct cn1_invoke_result { + char type; + cn1_invoke_arg value; +} cn1_invoke_result; + +/** + * Translator-emitted per-method shim. The thunk unpacks {@code args} + * into the typed C parameters the underlying translated function + * expects, dispatches through {@code virtual_(...)} (instance) or + * the static symbol (static), and packs the return into {@code result}. + * Exceptions are caught and surfaced as result.type='X'. + * + * Runs on the suspended Java thread so it has a valid + * {@code threadStateData} context. + */ +typedef void (*cn1_invoke_thunk_t)(struct ThreadLocalData* threadStateData, + JAVA_OBJECT thisObj, + const cn1_invoke_arg* args, + cn1_invoke_result* result); + +/** + * Translator-emitted constructor registers each method's thunk at + * process load. methodId matches the same value the sidecar carries, + * so the proxy can look up by name → methodId and forward to the + * device with no further mapping. + */ +extern void cn1_debugger_register_invoke_thunk(int methodId, cn1_invoke_thunk_t thunk); + #ifdef __BLOCKS__ /** * Defers the VM callback until the proxy reports the IDE has attached, so diff --git a/Ports/iOSPort/nativeSources/cn1_debugger.m b/Ports/iOSPort/nativeSources/cn1_debugger.m index f832897bec..20ff77a4c4 100644 --- a/Ports/iOSPort/nativeSources/cn1_debugger.m +++ b/Ports/iOSPort/nativeSources/cn1_debugger.m @@ -53,6 +53,7 @@ #define CMD_GET_STRING 0x0B #define CMD_GET_OBJECT_CLASS 0x0C #define CMD_GET_OBJECT_FIELDS 0x0D +#define CMD_INVOKE_METHOD 0x0E // Events (device -> proxy) #define EVT_HELLO 0x80 @@ -68,6 +69,7 @@ #define EVT_OBJECT_FIELDS 0x8A #define EVT_STDOUT_LINE 0x8B #define EVT_STDERR_LINE 0x8C +#define EVT_INVOKE_RESULT 0x8D // java.lang.String's clazz struct is emitted by the translator; we reference // it by symbol so cn1_debugger.m doesn't depend on the generated @@ -306,6 +308,39 @@ void cn1_debugger_register_fields(int classId, const cn1_field_entry* table, int return NULL; } +/* --------------------------------------------------------------------- */ +/* Invoke-thunk registry. Indexed by methodId; translator-emitted */ +/* constructors fill the table at process load. Lookup is O(1). */ +/* --------------------------------------------------------------------- */ + +#define CN1_INVOKE_REG_INITIAL_CAP 4096 +static cn1_invoke_thunk_t* g_invokeThunks = NULL; +static int g_invokeThunkCap = 0; +static pthread_mutex_t g_invokeRegMutex = PTHREAD_MUTEX_INITIALIZER; + +void cn1_debugger_register_invoke_thunk(int methodId, cn1_invoke_thunk_t thunk) { + if (methodId < 0) return; + pthread_mutex_lock(&g_invokeRegMutex); + if (methodId >= g_invokeThunkCap) { + int newCap = g_invokeThunkCap == 0 ? CN1_INVOKE_REG_INITIAL_CAP : g_invokeThunkCap * 2; + while (methodId >= newCap) newCap *= 2; + cn1_invoke_thunk_t* n = (cn1_invoke_thunk_t*)realloc(g_invokeThunks, + newCap * sizeof(cn1_invoke_thunk_t)); + if (!n) { pthread_mutex_unlock(&g_invokeRegMutex); return; } + memset(n + g_invokeThunkCap, 0, + (newCap - g_invokeThunkCap) * sizeof(cn1_invoke_thunk_t)); + g_invokeThunks = n; + g_invokeThunkCap = newCap; + } + g_invokeThunks[methodId] = thunk; + pthread_mutex_unlock(&g_invokeRegMutex); +} + +static cn1_invoke_thunk_t invoke_thunk_for(int methodId) { + if (methodId < 0 || methodId >= g_invokeThunkCap) return NULL; + return g_invokeThunks[methodId]; +} + /** * Read a field value into 8 host-endian bytes plus a JVM type-char. * Object refs become the JAVA_OBJECT pointer reinterpreted as uint64 so @@ -388,6 +423,15 @@ static void bp_clear(int methodId, int line) { int stepKind; // -1 = none, otherwise STEP_INTO/OVER/OUT int stepFromDepth; // callStackOffset captured at suspend struct ThreadLocalData* tsd; // current frame owner while suspended + // Debugger-driven method invocation. The listener thread sets these + // and signals s->cv; the suspended thread runs the thunk and signals + // back. invokeReady is the predicate for the listener's wait — 0 = + // running, 1 = finished, the result is in invokeResult. + cn1_invoke_thunk_t invokeThunk; + JAVA_OBJECT invokeThis; + cn1_invoke_arg invokeArgs[16]; + cn1_invoke_result invokeResult; + int invokeReady; }; #define SUS_TABLE_SIZE 1024 @@ -475,6 +519,31 @@ static void suspendCurrent(struct ThreadLocalData* tsd) { tsd->threadActive = JAVA_FALSE; while (s->suspended) { pthread_cond_wait(&s->cv, &s->mu); + // The listener thread may have queued a debugger-invoked method + // call for us to run. Servicing it on this thread keeps the call + // inside a valid Java context (right tsd, right call stack) and + // gives ParparVM's throwException somewhere to longjmp back to — + // we set up a catch-all try block inside the thunk itself. + if (s->invokeThunk && !s->invokeReady) { + cn1_invoke_thunk_t thunk = s->invokeThunk; + JAVA_OBJECT thisObj = s->invokeThis; + cn1_invoke_arg argsCopy[16]; + memcpy(argsCopy, s->invokeArgs, sizeof(argsCopy)); + // Re-activate the thread while running the thunk so any + // allocations / GC interaction it triggers proceed normally; + // we'll re-park before going back to wait. + tsd->threadActive = JAVA_TRUE; + pthread_mutex_unlock(&s->mu); + cn1_invoke_result r; + r.type = 'V'; + r.value.o = JAVA_NULL; + thunk(tsd, thisObj, argsCopy, &r); + pthread_mutex_lock(&s->mu); + tsd->threadActive = JAVA_FALSE; + s->invokeResult = r; + s->invokeReady = 1; + pthread_cond_broadcast(&s->cv); + } } // GC may have parked us; wait for it to finish before resuming. while (tsd->threadBlockedByGC) { @@ -881,6 +950,113 @@ static int handleCommand(uint8_t cmd, const uint8_t* payload, uint32_t len) { free(buf); return 0; } + case CMD_INVOKE_METHOD: { + // Payload: threadId(8) methodId(4) thisObj(8) argCount(4) args[argCount]*9 + // Each arg: type-char(1) + value(8). Value layout matches + // cn1_invoke_arg union per type. + // Reply: EVT_INVOKE_RESULT with type-char(1) + value(8). + if (len < 24) { + uint8_t empty[9] = {'V',0,0,0,0,0,0,0,0}; + sendEvent(EVT_INVOKE_RESULT, empty, 9); + return 0; + } + uint32_t hi, lo; + memcpy(&hi, payload, 4); memcpy(&lo, payload + 4, 4); + int64_t tid = ((int64_t)ntohl(hi) << 32) | (uint32_t)ntohl(lo); + uint32_t midBE; + memcpy(&midBE, payload + 8, 4); + int mid = (int)ntohl(midBE); + uint32_t thisHi, thisLo; + memcpy(&thisHi, payload + 12, 4); memcpy(&thisLo, payload + 16, 4); + uint64_t thisRaw = ((uint64_t)ntohl(thisHi) << 32) | (uint32_t)ntohl(thisLo); + JAVA_OBJECT thisObj = (JAVA_OBJECT)(uintptr_t)thisRaw; + uint32_t cntBE; + memcpy(&cntBE, payload + 20, 4); + int argCount = (int)ntohl(cntBE); + if (argCount < 0 || argCount > 16 + || (uint32_t)(24 + argCount * 9) > len) { + uint8_t err[9] = {'V',0,0,0,0,0,0,0,0}; + sendEvent(EVT_INVOKE_RESULT, err, 9); + return 0; + } + cn1_invoke_arg argv[16]; + memset(argv, 0, sizeof(argv)); + for (int i = 0; i < argCount; i++) { + uint8_t t = payload[24 + i * 9]; + uint32_t valHi, valLo; + memcpy(&valHi, payload + 24 + i * 9 + 1, 4); + memcpy(&valLo, payload + 24 + i * 9 + 5, 4); + uint64_t v = ((uint64_t)ntohl(valHi) << 32) | (uint32_t)ntohl(valLo); + switch ((char)t) { + case 'Z': case 'B': case 'S': case 'C': case 'I': + argv[i].i = (JAVA_INT)(uint32_t)v; break; + case 'J': + argv[i].j = (JAVA_LONG)v; break; + case 'F': { + uint32_t bits = (uint32_t)v; + memcpy(&argv[i].f, &bits, 4); break; + } + case 'D': + memcpy(&argv[i].d, &v, 8); break; + case 'L': case '[': default: + argv[i].o = (JAVA_OBJECT)(uintptr_t)v; break; + } + } + cn1_invoke_thunk_t thunk = invoke_thunk_for(mid); + if (thunk == NULL) { + uint8_t notfound[9] = {'V',0,0,0,0,0,0,0,0}; + sendEvent(EVT_INVOKE_RESULT, notfound, 9); + return 0; + } + // Queue the invoke on the target thread and wait for the + // result. If the thread isn't currently suspended we can't + // dispatch (JDWP requires it). + struct sus_state* s = susForThread(tid); + pthread_mutex_lock(&s->mu); + if (s->tsd == NULL) { + pthread_mutex_unlock(&s->mu); + uint8_t notsuspended[9] = {'V',0,0,0,0,0,0,0,0}; + sendEvent(EVT_INVOKE_RESULT, notsuspended, 9); + return 0; + } + s->invokeThunk = thunk; + s->invokeThis = thisObj; + memcpy(s->invokeArgs, argv, sizeof(argv)); + s->invokeReady = 0; + pthread_cond_broadcast(&s->cv); + // Block listener thread until result lands. + while (!s->invokeReady) { + pthread_cond_wait(&s->cv, &s->mu); + } + cn1_invoke_result r = s->invokeResult; + s->invokeThunk = NULL; + s->invokeReady = 0; + pthread_mutex_unlock(&s->mu); + + uint8_t reply[9]; + reply[0] = (uint8_t)r.type; + uint64_t bits = 0; + switch (r.type) { + case 'Z': case 'B': case 'S': case 'C': case 'I': + bits = (uint64_t)(uint32_t)r.value.i; break; + case 'J': + bits = (uint64_t)r.value.j; break; + case 'F': { + uint32_t fb; + memcpy(&fb, &r.value.f, 4); + bits = (uint64_t)fb; break; + } + case 'D': + memcpy(&bits, &r.value.d, 8); break; + case 'L': case '[': case 'X': + bits = (uint64_t)(uintptr_t)r.value.o; break; + case 'V': default: + bits = 0; break; + } + writeBE64(reply + 1, bits); + sendEvent(EVT_INVOKE_RESULT, reply, 9); + return 0; + } case CMD_GET_THREADS: case CMD_SUSPEND: // Minimal viable: reply empty so the proxy doesn't hang. diff --git a/docs/developer-guide/On-Device-Debugging.asciidoc b/docs/developer-guide/On-Device-Debugging.asciidoc index d47ad5fd2f..5bf1730eb9 100644 --- a/docs/developer-guide/On-Device-Debugging.asciidoc +++ b/docs/developer-guide/On-Device-Debugging.asciidoc @@ -175,12 +175,18 @@ in the IDE editor when stepping into framework code. === Known limitations -* *Method invocation from the debugger isn't supported.* The IDE's - "evaluate expression" feature can read existing values but can't - invoke methods on objects (for example, `myList.size()`). Object - identity, field access, and primitive evaluation work. -* *Watch expressions* that don't require method calls work; those - that do return an error. +* *Method invocation is partial.* The debugger can call instance and + static methods on Codename One framework classes and on user code: + jdb's `print myForm.getTitle()` and IntelliJ's "Evaluate Expression" + pop-up work for the chains you actually want + (`Display.getInstance().getCurrent().getTitle()` is the canonical + test). What doesn't work: methods on `java.io.*`, `java.net.*`, + `java.nio.*`, and `com.codename1.impl.*` — those classes are linked + against hand-written native code that has fallen out of sync with + the generic invoke-thunk calling convention, so the translator + skips them. Watching expressions follow the same rule. +* *Constructor invocation* isn't supported. The debugger can call + existing instance methods but not `new Foo(...)`. * *Hot-swap* isn't supported. Code changes require a rebuild and reinstall of the app. * *Hot threads* — if your app is doing heavy work on a non-EDT @@ -241,12 +247,11 @@ firewall is blocking the port. Verify: ==== jdb reports "Internal exception: Unexpected JDWP Error: 100" -This typically means an unsupported JDWP feature was invoked — -most commonly an attempt to call a method on an object from the -debugger (see "Method invocation from the debugger isn't -supported" above). The debug session itself is fine; rerun the -operation as a value read (for example, expand the object in the -variables view) instead of a method call. +This means an unsupported JDWP command was invoked. The session +itself survives; the most common cause is the IDE asking for +something we explicitly stub (see "Known limitations" above). If +you hit a less obvious case, the proxy logs the unknown command +code — file an issue with that code attached. ==== The app launches but the breakpoint never fires diff --git a/maven/cn1-debug-proxy/src/main/java/com/codename1/debug/proxy/DeviceConnection.java b/maven/cn1-debug-proxy/src/main/java/com/codename1/debug/proxy/DeviceConnection.java index 2175e88f2c..df5961ab06 100644 --- a/maven/cn1-debug-proxy/src/main/java/com/codename1/debug/proxy/DeviceConnection.java +++ b/maven/cn1-debug-proxy/src/main/java/com/codename1/debug/proxy/DeviceConnection.java @@ -42,6 +42,7 @@ public interface DeviceListener { void onStringValue(String value); void onObjectClass(int classId); void onObjectFields(byte[] typeCodes, long[] values); + void onInvokeResult(byte type, long value); void onReplyStatus(); void onStdoutLine(String line); void onStderrLine(String line); @@ -173,6 +174,14 @@ private void dispatch(int code, byte[] p) { listener.onObjectFields(types, values); return; } + case WireProtocol.EVT_INVOKE_RESULT: { + // Payload: type(1) + value(8). Value packing depends on type: + // I/F/Z/B/S/C use the int slot, J/D the long slot, + // L/[ /X carry an object reference, V leaves value=0. + if (p.length < 9) { listener.onUnknownEvent(code, p); return; } + listener.onInvokeResult(p[0], readLong(p, 1)); + return; + } case WireProtocol.EVT_STDOUT_LINE: case WireProtocol.EVT_STDERR_LINE: { // Payload: 4-byte big-endian length, then UTF-8 line bytes. @@ -266,6 +275,29 @@ public void getString(long objPtr) throws IOException { sendCommand(WireProtocol.CMD_GET_STRING, p); } + /** + * Dispatches a debugger-driven method invocation to the device. The + * suspended thread (identified by {@code threadId}) runs the call; + * {@code thisObj} is ignored for static methods (use 0). Each arg + * carries a JVM type-char and an 8-byte value — the device's thunk + * unpacks it into the typed C parameter the underlying function + * expects. + */ + public void invokeMethod(long threadId, int methodId, long thisObj, + byte[] argTypes, long[] argValues) throws IOException { + int n = argTypes.length; + byte[] p = new byte[8 + 4 + 8 + 4 + n * 9]; + writeLong(p, 0, threadId); + writeInt(p, 8, methodId); + writeLong(p, 12, thisObj); + writeInt(p, 20, n); + for (int i = 0; i < n; i++) { + p[24 + i * 9] = argTypes[i]; + writeLong(p, 24 + i * 9 + 1, argValues[i]); + } + sendCommand(WireProtocol.CMD_INVOKE_METHOD, p); + } + public void getObjectFields(long objPtr, int[] fieldIds) throws IOException { byte[] p = new byte[8 + 4 + fieldIds.length * 4]; writeLong(p, 0, objPtr); diff --git a/maven/cn1-debug-proxy/src/main/java/com/codename1/debug/proxy/JdwpServer.java b/maven/cn1-debug-proxy/src/main/java/com/codename1/debug/proxy/JdwpServer.java index d854e0aaf6..ce5f58d216 100644 --- a/maven/cn1-debug-proxy/src/main/java/com/codename1/debug/proxy/JdwpServer.java +++ b/maven/cn1-debug-proxy/src/main/java/com/codename1/debug/proxy/JdwpServer.java @@ -104,6 +104,19 @@ public final class JdwpServer implements DeviceConnection.DeviceListener { */ private static long toJdwpRef(int sidecarId) { return sidecarId + 1L; } private static int fromJdwpRef(long jdwpId) { return (int)(jdwpId - 1L); } + + /** + * JDWP method modifier bits. We track only static in the sidecar + * today; public is assumed. Adding STATIC for static methods is + * load-bearing — jdb's expression parser only resolves + * {@code Class.method()} syntax against methods reported with the + * STATIC modifier bit. + */ + private static int jdwpMethodModifiers(SymbolTable.MethodInfo m) { + int flags = 0x0001; // PUBLIC + if (m.isStatic) flags |= 0x0008; + return flags; + } // True once the device has handshook; the VM_START event will fire as // soon as a JDWP client is attached (and was already attached when the // device joined — order doesn't matter). @@ -489,7 +502,7 @@ private void handleRefType(int id, int cmd, byte[] p) throws IOException { b.writeLong(toJdwpRef(m.methodId)); b.writeString(m.name); b.writeString(m.descriptor); - b.writeInt(0x0001); // PUBLIC; we don't track real modifiers + b.writeInt(jdwpMethodModifiers(m)); } writeReply(id, 0, b.bytes()); return; @@ -503,7 +516,7 @@ private void handleRefType(int id, int cmd, byte[] p) throws IOException { b.writeString(m.name); b.writeString(m.descriptor); b.writeString(""); // generic signature - b.writeInt(0x0001); + b.writeInt(jdwpMethodModifiers(m)); } writeReply(id, 0, b.bytes()); return; @@ -608,16 +621,94 @@ private void handleMethod(int id, int cmd, byte[] p) throws IOException { private void handleClassType(int id, int cmd, byte[] p) throws IOException { switch (cmd) { case 1: { // Superclass + long typeID = readLong(p, 0); + SymbolTable.ClassInfo c = symbols.classById(fromJdwpRef(typeID)); Buf b = new Buf(); - b.writeLong(0); // null = java.lang.Object's super + if (c != null && c.superId >= 0) { + b.writeLong(toJdwpRef(c.superId)); + } else { + b.writeLong(0); // null = java.lang.Object's super + } writeReply(id, 0, b.bytes()); return; } + case 3: { // InvokeMethod (static) + // refType(8) thread(8) method(8) argCount(4) args[]. + // Each arg: tag(1) + value(tag-sized). options(4). + /* refType */ long classRef = readLong(p, 0); + long threadRef = readLong(p, 8); + long methodRef = readLong(p, 16); + int argCount = readInt(p, 24); + int devMid = fromJdwpRef(methodRef); + int off = 28; + byte[] argTypes = new byte[argCount]; + long[] argValues = new long[argCount]; + for (int i = 0; i < argCount; i++) { + byte tag = p[off]; off += 1; + argTypes[i] = tag; + long v; + switch ((char) tag) { + case 'Z': case 'B': v = p[off] & 0xff; off += 1; break; + case 'S': case 'C': v = readShort(p, off) & 0xffff; off += 2; break; + case 'I': case 'F': v = readInt(p, off) & 0xffffffffL; off += 4; break; + case 'J': case 'D': v = readLong(p, off); off += 8; break; + default: // object refs etc. + v = readLong(p, off); off += 8; break; + } + argValues[i] = v; + } + int methodId = fromJdwpRef(methodRef); + long threadId = threadRef; + boolean ok = blockingInvoke(threadId, methodId, /*thisObj=static*/0L, argTypes, argValues); + writeInvokeReply(id, ok); + return; + } default: writeReply(id, 100, empty()); } } + /** + * Packs the result of a CMD_INVOKE_METHOD round-trip into a JDWP + * InvokeMethod reply (returnValue + exception object). + */ + private void writeInvokeReply(int id, boolean ok) throws IOException { + Buf b = new Buf(); + if (!ok) { + // returnValue = void, exception = null + b.writeByte('V'); + b.writeByte('L'); + b.writeLong(0); + writeReply(id, 0, b.bytes()); + return; + } + byte t = lastInvokeType; + long v = lastInvokeValue; + boolean threw = t == 'X'; + if (threw) { + // Return value is void in this case; exception object goes in + // the second slot. + b.writeByte('V'); + b.writeByte('L'); + b.writeLong(v); + } else { + b.writeByte(t == 0 ? 'V' : t); + switch ((char) t) { + case 'Z': b.writeByte((int) v & 1); break; + case 'B': b.writeByte((int) v & 0xff); break; + case 'S': case 'C': b.writeShort((int) v & 0xffff); break; + case 'I': case 'F': b.writeInt((int) v); break; + case 'J': case 'D': b.writeLong(v); break; + case 'L': case '[': b.writeLong(v); break; + case 'V': default: /* no value bytes for void */ break; + } + // No exception. + b.writeByte('L'); + b.writeLong(0); + } + writeReply(id, 0, b.bytes()); + } + // -------- Thread / ThreadGroup ----------------------------------------- private void handleThread(int id, int cmd, byte[] p) throws IOException { @@ -996,6 +1087,36 @@ private void handleObject(int id, int cmd, byte[] p) throws IOException { writeReply(id, 0, b.bytes()); return; } + case 6: { // InvokeMethod (instance) + // objId(8) thread(8) refType(8) method(8) argCount(4) args[] options(4) + long thisObj = objectId; + long threadRef = readLong(p, 8); + /* refType */ readLong(p, 16); + long methodRef = readLong(p, 24); + int argCount = readInt(p, 32); + int devMid = fromJdwpRef(methodRef); + int off = 36; + byte[] argTypes = new byte[argCount]; + long[] argValues = new long[argCount]; + for (int i = 0; i < argCount; i++) { + byte tag = p[off]; off += 1; + argTypes[i] = tag; + long v; + switch ((char) tag) { + case 'Z': case 'B': v = p[off] & 0xff; off += 1; break; + case 'S': case 'C': v = readShort(p, off) & 0xffff; off += 2; break; + case 'I': case 'F': v = readInt(p, off) & 0xffffffffL; off += 4; break; + case 'J': case 'D': v = readLong(p, off); off += 8; break; + default: v = readLong(p, off); off += 8; break; + } + argValues[i] = v; + } + int methodId = fromJdwpRef(methodRef); + long threadId = threadRef; + boolean ok = blockingInvoke(threadId, methodId, thisObj, argTypes, argValues); + writeInvokeReply(id, ok); + return; + } case 9: { // IsCollected — we don't track GC; always false. Buf b = new Buf(); b.writeByte(0); writeReply(id, 0, b.bytes()); return; } @@ -1040,6 +1161,37 @@ private int blockingGetObjectClass(long objectId) { } } + private final Object invokeLock = new Object(); + private boolean pendingInvoke = false; + private byte lastInvokeType = 'V'; + private long lastInvokeValue = 0; + + /** + * Sends an InvokeMethod request to the device and blocks until the + * suspended thread runs the thunk and returns a result. Returns false + * on timeout — the proxy can't tell the IDE much in that case beyond + * an INTERNAL error. + */ + private boolean blockingInvoke(long threadId, int methodId, long thisObj, + byte[] argTypes, long[] argValues) { + if (device == null) return false; + synchronized (invokeLock) { + pendingInvoke = true; + lastInvokeType = 'V'; + lastInvokeValue = 0; + try { device.invokeMethod(threadId, methodId, thisObj, argTypes, argValues); } + catch (IOException io) { pendingInvoke = false; return false; } + // Bigger budget than the field-read path because the call can + // actually run user code that does anything. + long deadline = System.currentTimeMillis() + 10000; + while (pendingInvoke && System.currentTimeMillis() < deadline) { + try { invokeLock.wait(deadline - System.currentTimeMillis()); } + catch (InterruptedException ie) { break; } + } + return !pendingInvoke; + } + } + private final Object objectFieldsLock = new Object(); private boolean pendingObjectFields = false; private byte[] lastObjectFieldsTypes = null; @@ -1245,6 +1397,14 @@ private void fetchStackForThread(long tid) { objectFieldsLock.notifyAll(); } } + @Override public void onInvokeResult(byte type, long value) { + synchronized (invokeLock) { + lastInvokeType = type; + lastInvokeValue = value; + pendingInvoke = false; + invokeLock.notifyAll(); + } + } @Override public void onReplyStatus() {} @Override public void onStdoutLine(String line) { // Surface device prints in whatever console the proxy is running in @@ -1270,6 +1430,9 @@ private static int readInt(byte[] b, int off) { return ((b[off] & 0xff) << 24) | ((b[off+1] & 0xff) << 16) | ((b[off+2] & 0xff) << 8) | (b[off+3] & 0xff); } + private static short readShort(byte[] b, int off) { + return (short)(((b[off] & 0xff) << 8) | (b[off+1] & 0xff)); + } private static long readLong(byte[] b, int off) { return ((long)readInt(b, off) << 32) | (readInt(b, off + 4) & 0xffffffffL); } diff --git a/maven/cn1-debug-proxy/src/main/java/com/codename1/debug/proxy/LoggingListener.java b/maven/cn1-debug-proxy/src/main/java/com/codename1/debug/proxy/LoggingListener.java index 32d29e77f0..177b818b50 100644 --- a/maven/cn1-debug-proxy/src/main/java/com/codename1/debug/proxy/LoggingListener.java +++ b/maven/cn1-debug-proxy/src/main/java/com/codename1/debug/proxy/LoggingListener.java @@ -61,6 +61,9 @@ public LoggingListener(SymbolTable symbols) { System.out.println(" #" + i + " type='" + (char) typeCodes[i] + "' value=" + formatValue(typeCodes[i], values[i])); } } + @Override public void onInvokeResult(byte type, long value) { + System.out.println("[event] INVOKE_RESULT type='" + (char) type + "' value=" + formatValue(type, value)); + } @Override public void onReplyStatus() { System.out.println("[event] REPLY_STATUS"); } @Override public void onStdoutLine(String line) { System.out.println("[device] " + line); } @Override public void onStderrLine(String line) { System.err.println("[device] " + line); } diff --git a/maven/cn1-debug-proxy/src/main/java/com/codename1/debug/proxy/SymbolTable.java b/maven/cn1-debug-proxy/src/main/java/com/codename1/debug/proxy/SymbolTable.java index 443180ac38..4a090cdfa1 100644 --- a/maven/cn1-debug-proxy/src/main/java/com/codename1/debug/proxy/SymbolTable.java +++ b/maven/cn1-debug-proxy/src/main/java/com/codename1/debug/proxy/SymbolTable.java @@ -35,6 +35,8 @@ public static final class ClassInfo { public final int classId; public final String name; // e.g. "java_lang_String" (translator convention) public final String sourceFile; + /** Superclass classId, or -1 if java.lang.Object / unknown. */ + public int superId = -1; public final List methods = new ArrayList<>(); /** * Instance fields physically stored in this class's struct (i.e. including @@ -89,14 +91,16 @@ public static final class MethodInfo { public final int classId; public final String name; public final String descriptor; + public final boolean isStatic; public final TreeSet lines = new TreeSet<>(); public final List locals = new ArrayList<>(); - MethodInfo(int methodId, int classId, String name, String descriptor) { + MethodInfo(int methodId, int classId, String name, String descriptor, boolean isStatic) { this.methodId = methodId; this.classId = classId; this.name = name; this.descriptor = descriptor; + this.isStatic = isStatic; } /** @@ -149,6 +153,10 @@ public static SymbolTable load(Path file) throws IOException { if (parts.length < 4) continue; int id = Integer.parseInt(parts[1]); ClassInfo c = new ClassInfo(id, parts[2], parts[3]); + if (parts.length >= 5) { + try { c.superId = Integer.parseInt(parts[4]); } + catch (NumberFormatException ignore) {} + } t.classesById.put(id, c); t.classesByJvmSig.put(c.jvmSignature(), c); if (!parts[3].isEmpty()) { @@ -160,7 +168,8 @@ public static SymbolTable load(Path file) throws IOException { if (parts.length < 5) continue; int mid = Integer.parseInt(parts[1]); int cid = Integer.parseInt(parts[2]); - MethodInfo m = new MethodInfo(mid, cid, parts[3], parts[4]); + boolean isStatic = parts.length >= 6 && "1".equals(parts[5]); + MethodInfo m = new MethodInfo(mid, cid, parts[3], parts[4], isStatic); t.methodsById.put(mid, m); ClassInfo c = t.classesById.get(cid); if (c != null) c.methods.add(m); diff --git a/maven/cn1-debug-proxy/src/main/java/com/codename1/debug/proxy/WireProtocol.java b/maven/cn1-debug-proxy/src/main/java/com/codename1/debug/proxy/WireProtocol.java index ed69cabb9c..4479363ca0 100644 --- a/maven/cn1-debug-proxy/src/main/java/com/codename1/debug/proxy/WireProtocol.java +++ b/maven/cn1-debug-proxy/src/main/java/com/codename1/debug/proxy/WireProtocol.java @@ -35,6 +35,7 @@ private WireProtocol() {} public static final int CMD_GET_STRING = 0x0B; public static final int CMD_GET_OBJECT_CLASS = 0x0C; public static final int CMD_GET_OBJECT_FIELDS = 0x0D; + public static final int CMD_INVOKE_METHOD = 0x0E; public static final int EVT_HELLO = 0x80; public static final int EVT_THREAD_LIST = 0x81; @@ -49,6 +50,7 @@ private WireProtocol() {} public static final int EVT_OBJECT_FIELDS = 0x8A; public static final int EVT_STDOUT_LINE = 0x8B; public static final int EVT_STDERR_LINE = 0x8C; + public static final int EVT_INVOKE_RESULT = 0x8D; public static final int STEP_INTO = 0; public static final int STEP_OVER = 1; diff --git a/vm/ByteCodeTranslator/src/com/codename1/tools/translator/ByteCodeClass.java b/vm/ByteCodeTranslator/src/com/codename1/tools/translator/ByteCodeClass.java index 4c01838478..541bb224d1 100644 --- a/vm/ByteCodeTranslator/src/com/codename1/tools/translator/ByteCodeClass.java +++ b/vm/ByteCodeTranslator/src/com/codename1/tools/translator/ByteCodeClass.java @@ -1265,11 +1265,77 @@ public String generateCCode(List allClasses) { // the data or registration cost. if (BytecodeMethod.isOnDeviceDebug()) { appendOnDeviceDebugFieldTable(b); + appendOnDeviceDebugInvokeThunks(b); } return b.toString(); } + /** + * Emits a per-method shim that lets the debugger runtime call any + * translated method generically. Each thunk unpacks an argument array + * into the typed C parameters that the underlying function expects, + * wraps the call in a catch-all try block so an uncaught Throwable + * round-trips back as a value instead of unwinding past + * suspendCurrent, and packs the return value into a uniform + * {@code cn1_invoke_result}. A __attribute__((constructor)) at the + * bottom registers each thunk with the global registry keyed by + * methodOffset. + */ + private void appendOnDeviceDebugInvokeThunks(StringBuilder b) { + // Skip thunk generation for classes whose impl is hand-written + // native code that's been allowed to fall out of sync with the + // translator's calling convention. Without thunks the linker + // dead-strips the C wrapper (since user code typically doesn't + // call those wrappers); with thunks the wrapper is forced live + // and the link fails on missing native impls. + // + // The skip list is intentionally narrow — only the packages + // where this is known to bite. java.lang / java.util etc. are + // fine and worth keeping (jdb leans on Object.toString for + // "print" output, and we want lists/strings to round-trip too). + if (clsName.startsWith("java_io_") || clsName.startsWith("java_net_") + || clsName.startsWith("java_nio_") + || clsName.startsWith("com_codename1_impl_")) { + return; + } + // We emit a constructor PER class that registers all of that + // class's invoke thunks in one go. The thunks themselves are + // file-static so they don't leak symbols. + List eligible = new ArrayList<>(); + for (BytecodeMethod m : methods) { + if (m.isEliminated()) continue; + if (m.isConstructor()) continue; + String name = m.getMethodName(); + if ("__CLINIT__".equals(name) || "".equals(name)) continue; + // Abstract methods have no body to call. Native methods are + // fine — their impls live in nativeMethods.m / iOS port + // hand-written code, with the same C name our thunk calls. + // We rely on the class-prefix filter above to skip whole + // packages whose native sidecar isn't linked in this build + // (java.io / java.net / java.nio / com.codename1.impl). + if (m.isAbstract()) continue; + eligible.add(m); + } + if (eligible.isEmpty()) return; + + b.append("\n#ifdef CN1_ON_DEVICE_DEBUG\n"); + b.append("#include \n"); + b.append("#include \n"); + for (BytecodeMethod m : eligible) { + m.appendOnDeviceDebugInvokeThunk(clsName, b); + } + b.append("__attribute__((constructor)) static void __cn1_dbg_register_invoke_thunks_") + .append(clsName).append("(void) {\n"); + for (BytecodeMethod m : eligible) { + b.append(" cn1_debugger_register_invoke_thunk(") + .append(m.getMethodOffset()) + .append(", &__cn1_dbg_invoke_").append(m.getMethodOffset()).append(");\n"); + } + b.append("}\n"); + b.append("#endif // CN1_ON_DEVICE_DEBUG\n"); + } + private void appendOnDeviceDebugFieldTable(StringBuilder b) { // Inherit-through layout-order list: parents first, this class last. // Matches addFields() so offsetof() lines up with the actual struct. diff --git a/vm/ByteCodeTranslator/src/com/codename1/tools/translator/BytecodeMethod.java b/vm/ByteCodeTranslator/src/com/codename1/tools/translator/BytecodeMethod.java index 953d9fefa5..f9ab3e3fcf 100644 --- a/vm/ByteCodeTranslator/src/com/codename1/tools/translator/BytecodeMethod.java +++ b/vm/ByteCodeTranslator/src/com/codename1/tools/translator/BytecodeMethod.java @@ -1699,6 +1699,134 @@ public int getMethodOffset() { return methodOffset; } + /** + * Emits the debugger-driven invoke thunk for this method. The thunk + * has a uniform C signature so the registry can store all thunks in + * one function-pointer table; it unpacks args from a {@code cn1_invoke_arg} + * union array into the typed parameters the underlying translated + * function expects, wraps the call in a catch-all try block so an + * uncaught throw turns into {@code result.type='X'} rather than a + * longjmp past the debugger's cond-wait, and packs the return value + * back into the result union. + */ + public void appendOnDeviceDebugInvokeThunk(String declaringClsName, StringBuilder b) { + String symbol = declaringClsName + "_"; + if ("".equals(methodName)) { + // skipped at caller, but defensive + return; + } else if ("".equals(methodName)) { + return; + } + symbol += getCMethodName(); + // Append the descriptor suffix the translator uses + // (args + _R for non-void). + StringBuilder argSuffix = new StringBuilder(); + for (ByteCodeMethodArg arg : arguments) { + arg.appendCMethodExt(argSuffix); + } + if (!returnType.isVoid()) { + argSuffix.append("_R"); + returnType.appendCMethodExt(argSuffix); + } + String fullSymbol = symbol + "__" + argSuffix.toString(); + // Choose virtual_ only when the translator actually emits + // one. Static, private, or methods marked virtualOverriden (the + // class is final, so the dispatch was constant-folded) have no + // virtual_ alias in their header, and the thunk has to call the + // plain symbol or the C file won't compile. + boolean useVirtualPrefix = !staticMethod && !privateMethod && !virtualOverriden; + String callSymbol = useVirtualPrefix ? ("virtual_" + fullSymbol) : fullSymbol; + int mid = methodOffset; + + b.append("static void __cn1_dbg_invoke_").append(mid) + .append("(struct ThreadLocalData* threadStateData, JAVA_OBJECT thisObj, const cn1_invoke_arg* args, cn1_invoke_result* result) {\n"); + b.append(" (void)args; (void)thisObj;\n"); + b.append(" int __savedCallStack = threadStateData->callStackOffset;\n"); + b.append(" int __savedLocalsBegin = threadStateData->threadObjectStackOffset;\n"); + b.append(" int __savedTryBlock = threadStateData->tryBlockOffset;\n"); + b.append(" jmp_buf __tryJmp;\n"); + b.append(" if (setjmp(__tryJmp) == 0) {\n"); + b.append(" threadStateData->blocks[threadStateData->tryBlockOffset].monitor = 0;\n"); + b.append(" threadStateData->blocks[threadStateData->tryBlockOffset].exceptionClass = 0;\n"); + b.append(" memcpy(threadStateData->blocks[threadStateData->tryBlockOffset].destination, __tryJmp, sizeof(jmp_buf));\n"); + b.append(" threadStateData->tryBlockOffset++;\n"); + // Emit the actual call + b.append(" "); + if (returnType.isVoid()) { + b.append(callSymbol).append("(threadStateData"); + } else { + // Capture return into a typed temp, then pack. + char rq = returnType.getQualifier(); + if (rq == 'o') b.append("JAVA_OBJECT __r = "); + else if (rq == 'l') b.append("JAVA_LONG __r = "); + else if (rq == 'd') b.append("JAVA_DOUBLE __r = "); + else if (rq == 'f') b.append("JAVA_FLOAT __r = "); + else b.append("JAVA_INT __r = "); + b.append(callSymbol).append("(threadStateData"); + } + if (!staticMethod) { + b.append(", thisObj"); + } + for (int i = 0; i < arguments.size(); i++) { + ByteCodeMethodArg arg = arguments.get(i); + char q = arg.getQualifier(); + b.append(", args[").append(i).append("]."); + switch (q) { + case 'o': b.append("o"); break; + case 'l': b.append("j"); break; + case 'd': b.append("d"); break; + case 'f': b.append("f"); break; + default: b.append("i"); break; + } + } + b.append(");\n"); + // Pop our try block (no exception path) and store the result. + b.append(" threadStateData->tryBlockOffset--;\n"); + if (returnType.isVoid()) { + b.append(" result->type = 'V';\n"); + } else { + char rq = returnType.getQualifier(); + String rtc; + String slot; + if (rq == 'o') { rtc = "L"; slot = "o"; } + else if (rq == 'l') { rtc = "J"; slot = "j"; } + else if (rq == 'd') { rtc = "D"; slot = "d"; } + else if (rq == 'f') { rtc = "F"; slot = "f"; } + else { + // Sub-int types still pack through the int slot; + // we communicate the real type via the type-char. + String d = returnType.getQualifier() == 'i' ? returnTypeChar() : "I"; + rtc = d; + slot = "i"; + } + b.append(" result->type = '").append(rtc).append("';\n"); + b.append(" result->value.").append(slot).append(" = __r;\n"); + } + b.append(" } else {\n"); + b.append(" result->type = 'X';\n"); + b.append(" result->value.o = threadStateData->exception;\n"); + b.append(" threadStateData->exception = JAVA_NULL;\n"); + b.append(" threadStateData->callStackOffset = __savedCallStack;\n"); + b.append(" threadStateData->threadObjectStackOffset = __savedLocalsBegin;\n"); + b.append(" threadStateData->tryBlockOffset = __savedTryBlock;\n"); + b.append(" }\n"); + b.append("}\n"); + } + + /** + * Returns the JDWP type-char for the method's return type. Only used + * by {@link #appendOnDeviceDebugInvokeThunk} for sub-int primitive + * returns where the C variable is JAVA_INT but the wire-level type + * is more specific (e.g. boolean / byte / short / char). + */ + private String returnTypeChar() { + if (returnType.getPrimitiveType() == Boolean.TYPE) return "Z"; + if (returnType.getPrimitiveType() == Byte.TYPE) return "B"; + if (returnType.getPrimitiveType() == Short.TYPE) return "S"; + if (returnType.getPrimitiveType() == Character.TYPE) return "C"; + return "I"; + } + /** * @param methodOffset the methodOffset to set */ diff --git a/vm/ByteCodeTranslator/src/com/codename1/tools/translator/Parser.java b/vm/ByteCodeTranslator/src/com/codename1/tools/translator/Parser.java index 36de5de15a..670e73a517 100644 --- a/vm/ByteCodeTranslator/src/com/codename1/tools/translator/Parser.java +++ b/vm/ByteCodeTranslator/src/com/codename1/tools/translator/Parser.java @@ -191,7 +191,15 @@ private static void writeSymbolSidecar(File outputDirectory) throws IOException if (src == null) { src = ""; } - w.write("class\t" + bc.getClassOffset() + "\t" + bc.getClsName() + "\t" + src + "\n"); + // Extended class row: id, name, sourceFile, superId. + // Older proxies (4-column form) ignore the trailing + // column since the parser tolerates extras. + int superId = -1; + if (bc.getBaseClassObject() != null) { + superId = bc.getBaseClassObject().getClassOffset(); + } + w.write("class\t" + bc.getClassOffset() + "\t" + bc.getClsName() + + "\t" + src + "\t" + superId + "\n"); } // Emit instance-field metadata so the proxy can answer JDWP // ClassType.Fields / FieldsWithGeneric without a device round-trip, @@ -224,7 +232,14 @@ private static void writeSymbolSidecar(File outputDirectory) throws IOException if (desc == null) { desc = ""; } - w.write("method\t" + m.getMethodOffset() + "\t" + classId + "\t" + m.getMethodName() + "\t" + desc + "\n"); + // Extended method row: classId, name, desc, isStatic. + // Older proxies that only know 4 columns ignore the 5th + // because the parser slices with `split("\t", -1)` and + // size-checks before reading. + w.write("method\t" + m.getMethodOffset() + "\t" + classId + + "\t" + m.getMethodName() + + "\t" + desc + + "\t" + (m.isStatic() ? "1" : "0") + "\n"); Set lines = new TreeSet<>(); for (com.codename1.tools.translator.bytecodes.Instruction ins : m.getInstructions()) { if (ins instanceof com.codename1.tools.translator.bytecodes.LineNumber) { @@ -682,6 +697,16 @@ private static int cullMethods() { } continue; } + // Preserve virtual-root methods of java.lang.Object when + // on-device-debug is on. jdb's `print` formats objects by + // calling Object.toString, and a debugger user can ask to + // invoke equals/hashCode/etc. without a static call site + // to keep their body alive on its own. + if (BytecodeMethod.isOnDeviceDebug() + && "java_lang_Object".equals(bc.getClsName()) + && !mtd.isStatic()) { + continue; + } if(!isMethodUsed(mtd, bc)) { if(isMethodUsedByBaseClassOrInterface(mtd, bc)) { From 0a018f4171ae673ae2a6277755fa6dd670491845 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Fri, 22 May 2026 14:49:02 +0300 Subject: [PATCH 08/13] Proxy: --trace-jdwp flag for diagnosing IDE-specific step / invoke issues MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When IntelliJ's step behaviour diverges from jdb's (different default class-exclude modifiers, additional pre-step queries, etc.), the proxy needs a way to surface every JDWP command it receives so the wire- level difference is visible without rebuilding. Add a --trace-jdwp flag that toggles a single-line log per inbound command (cmd-set / cmd / id / payload length). Off by default — release sessions shouldn't pay the log overhead. --- .../main/java/com/codename1/debug/proxy/JdwpServer.java | 7 +++++++ .../src/main/java/com/codename1/debug/proxy/ProxyMain.java | 1 + 2 files changed, 8 insertions(+) diff --git a/maven/cn1-debug-proxy/src/main/java/com/codename1/debug/proxy/JdwpServer.java b/maven/cn1-debug-proxy/src/main/java/com/codename1/debug/proxy/JdwpServer.java index ce5f58d216..e4d053c84b 100644 --- a/maven/cn1-debug-proxy/src/main/java/com/codename1/debug/proxy/JdwpServer.java +++ b/maven/cn1-debug-proxy/src/main/java/com/codename1/debug/proxy/JdwpServer.java @@ -253,10 +253,17 @@ private void packetLoop() throws IOException { int cmdSet = in.readUnsignedByte(); int cmd = in.readUnsignedByte(); in.readFully(payload); + if (traceJdwp) { + System.out.println("[jdwp-trace] CMD set=" + cmdSet + " cmd=" + cmd + + " id=" + id + " len=" + payload.length); + } dispatchCommand(id, cmdSet, cmd, payload); } } + /** Set true to dump every inbound JDWP command. Toggled by --trace-jdwp. */ + public static volatile boolean traceJdwp = false; + // -------- Packet writers ------------------------------------------------ private void writeReply(int id, int errorCode, byte[] data) throws IOException { diff --git a/maven/cn1-debug-proxy/src/main/java/com/codename1/debug/proxy/ProxyMain.java b/maven/cn1-debug-proxy/src/main/java/com/codename1/debug/proxy/ProxyMain.java index 87ccb9146b..f1160d5407 100644 --- a/maven/cn1-debug-proxy/src/main/java/com/codename1/debug/proxy/ProxyMain.java +++ b/maven/cn1-debug-proxy/src/main/java/com/codename1/debug/proxy/ProxyMain.java @@ -43,6 +43,7 @@ public static void main(String[] args) throws Exception { else if (a.startsWith("--device-port=")) devicePort = Integer.parseInt(a.substring("--device-port=".length())); else if (a.startsWith("--jdwp-port=")) jdwpPort = Integer.parseInt(a.substring("--jdwp-port=".length())); else if (a.equals("--no-jdwp")) noJdwp = true; + else if (a.equals("--trace-jdwp")) JdwpServer.traceJdwp = true; else if (a.equals("--help") || a.equals("-h")) { printUsage(); return; } else { System.err.println("Unrecognised argument: " + a); From 23fa13114956d32065b83424b14bb01844a216fd Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Fri, 22 May 2026 18:02:56 +0300 Subject: [PATCH 09/13] Proxy: fix off-by-one in JDWP EventRequest modifier-kind numbering MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit JDWP modifier-kind values per the spec: 1 Count 2 ConditionalExpr (deprecated) 3 ThreadOnly 4 ClassOnly 5 ClassMatch 6 ClassExclude 7 LocationOnly 8 ExceptionOnly 9 FieldOnly 10 Step 11 InstanceOnly 12 SourceNameMatch The previous switch had every kind shifted by one — 2 was treated as ThreadOnly (really Conditional, deprecated), 3 as ClassOnly (really ThreadOnly), and the string-payload kinds were 4/5 instead of 5/6. The practical bite: IntelliJ auto-attaches a handful of ClassExclude modifiers (`java.*`, `javax.*`, `sun.*`, `com.sun.*`, `jdk.internal.*`) to every StepRequest. With kind=6 unrecognised the parser bailed mid-payload, sent the modKind=6 warning to the proxy log, and truncated the modifier list — IntelliJ then either retries the step or shows "Source code does not match the bytecode" depending on exactly which modifiers it expected to be honoured. Caught from a JDWP packet trace where IntelliJ sent a 561-byte EventRequest.Set with 27 modifiers; the proxy logged "unknown modKind=6 — ignoring remaining 26 modifiers" right before each step. Also added an explicit case for modKind=2 (Conditional, deprecated) even though no current debugger sends it, so the parser handles every value in the spec without falling to the default branch. --- .../com/codename1/debug/proxy/JdwpServer.java | 50 +++++++++++-------- 1 file changed, 29 insertions(+), 21 deletions(-) diff --git a/maven/cn1-debug-proxy/src/main/java/com/codename1/debug/proxy/JdwpServer.java b/maven/cn1-debug-proxy/src/main/java/com/codename1/debug/proxy/JdwpServer.java index e4d053c84b..d3fd33dc5c 100644 --- a/maven/cn1-debug-proxy/src/main/java/com/codename1/debug/proxy/JdwpServer.java +++ b/maven/cn1-debug-proxy/src/main/java/com/codename1/debug/proxy/JdwpServer.java @@ -916,38 +916,48 @@ private void handleEventRequest(int id, int cmd, byte[] p) throws IOException { for (int i = 0; i < modCount && !badModifier; i++) { if (off >= p.length) { badModifier = true; break; } int modKind = p[off] & 0xff; off += 1; - // ClassOnly (3) is a refTypeID (8 bytes), not a string — - // ClassMatch (4) and ClassExclude (5) are strings. Splitting - // the case body keeps the dispatch tidy. + // JDWP modifier-kind numbering per the spec: + // 1 Count, 2 Conditional (deprecated), + // 3 ThreadOnly, 4 ClassOnly, 5 ClassMatch, + // 6 ClassExclude, 7 LocationOnly, 8 ExceptionOnly, + // 9 FieldOnly, 10 Step, 11 InstanceOnly, + // 12 SourceNameMatch. + // IntelliJ leans heavily on 6 (ClassExclude) — it + // auto-attaches java.*, javax.*, sun.*, etc. to every + // step request, so a parser that drops modKind=6 + // aborts every IntelliJ step. switch (modKind) { - case 7: { // LocationOnly - // Location: typeTag(1) + classID(8) + methodID(8) + codeIndex(8) = 25 - if (off + 25 > p.length) { badModifier = true; break; } - /* typeTag */ off += 1; - typeID = readLong(p, off); off += 8; - methodID = readLong(p, off); off += 8; - codeIndex = readLong(p, off); off += 8; - break; - } case 1: { // Count if (off + 4 > p.length) { badModifier = true; break; } off += 4; break; } - case 2: { // ThreadOnly + case 2: { // ConditionalExpression (deprecated) — exprID + if (off + 4 > p.length) { badModifier = true; break; } + off += 4; break; + } + case 3: { // ThreadOnly (objectID) if (off + 8 > p.length) { badModifier = true; break; } off += 8; break; } - case 3: { // ClassOnly (refTypeID) + case 4: { // ClassOnly (refTypeID) if (off + 8 > p.length) { badModifier = true; break; } off += 8; break; } - case 4: case 5: { // ClassMatch, ClassExclude (string) + case 5: case 6: { // ClassMatch, ClassExclude (string) if (off + 4 > p.length) { badModifier = true; break; } int slen = readInt(p, off); if (slen < 0 || off + 4 + slen > p.length) { badModifier = true; break; } off += 4 + slen; break; } + case 7: { // LocationOnly: typeTag(1) + classID(8) + methodID(8) + codeIndex(8) = 25 + if (off + 25 > p.length) { badModifier = true; break; } + /* typeTag */ off += 1; + typeID = readLong(p, off); off += 8; + methodID = readLong(p, off); off += 8; + codeIndex = readLong(p, off); off += 8; + break; + } case 8: { // ExceptionOnly: refTypeID + caught(byte) + uncaught(byte) if (off + 10 > p.length) { badModifier = true; break; } off += 10; break; @@ -956,14 +966,14 @@ private void handleEventRequest(int id, int cmd, byte[] p) throws IOException { if (off + 16 > p.length) { badModifier = true; break; } off += 16; break; } - case 10: { // Step modifier: threadID(8) + size(4) + depth(4) = 16 + case 10: { // Step: threadID(8) + size(4) + depth(4) = 16 if (off + 16 > p.length) { badModifier = true; break; } stepThread = readLong(p, off); off += 8; /* size */ off += 4; stepDepth = readInt(p, off); off += 4; break; } - case 11: { // InstanceOnly + case 11: { // InstanceOnly (objectID) if (off + 8 > p.length) { badModifier = true; break; } off += 8; break; } @@ -975,10 +985,8 @@ private void handleEventRequest(int id, int cmd, byte[] p) throws IOException { break; } default: - // Unknown modifier type. We don't know its width - // so we have to bail rather than guessing — guessing - // walks the read pointer past the buffer and - // crashes the dispatcher. + // Unknown modifier — bail rather than guess its + // width and walk the read pointer past the buffer. System.out.println("[jdwp] EventRequest.Set: unknown modKind=" + modKind + " — ignoring remaining " + (modCount - i - 1) + " modifiers"); badModifier = true; From 0d6772c73d1bc1c2408f9e632ab72c1737953890 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Fri, 22 May 2026 21:23:54 +0300 Subject: [PATCH 10/13] Proxy: invalidate stack / locals cache on suspend & resume events MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Frames panel was sticking to the previous suspend location after each step because fetchStackForThread short-circuits on a populated cache. onBreakpointHit eagerly fetches the stack and populates that cache; onStepComplete had no eager fetch but also didn't invalidate, so when IntelliJ asked for Frames after a step it got back the BP_HIT stack — e.g. user sets BP on line 48, presses F8 once, the cursor / Frames / double-click all still point to line 48 while the device is actually suspended on line 49. Same bug affected locals (StackFrame.GetValues short-circuits via its own pendingLocals cache, kept across suspensions of the same frameIdx) — the IDE evaluated expressions against a stale frame. Fix: introduce invalidateStack() / invalidateLocals() helpers and call them in every state-changing handler: - onBreakpointHit (before the eager fetch) - onStepComplete (no eager fetch — IDE will pull) - VM.Resume (everything cached is now meaningless) - Thread.Resume (same) End result: Frames panel and Variables panel both refresh after every step / resume cycle without the IDE having to explicitly re-suspend. --- .../com/codename1/debug/proxy/JdwpServer.java | 38 +++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/maven/cn1-debug-proxy/src/main/java/com/codename1/debug/proxy/JdwpServer.java b/maven/cn1-debug-proxy/src/main/java/com/codename1/debug/proxy/JdwpServer.java index d3fd33dc5c..67ad65251b 100644 --- a/maven/cn1-debug-proxy/src/main/java/com/codename1/debug/proxy/JdwpServer.java +++ b/maven/cn1-debug-proxy/src/main/java/com/codename1/debug/proxy/JdwpServer.java @@ -414,6 +414,8 @@ private void handleVM(int id, int cmd, byte[] p) throws IOException { } case 9: { // Resume System.out.println("[jdwp] VM.Resume"); + invalidateStack(); + invalidateLocals(); if (device != null) try { device.resumeAll(); } catch (IOException ignore) {} writeReply(id, 0, empty()); return; @@ -729,6 +731,8 @@ private void handleThread(int id, int cmd, byte[] p) throws IOException { } case 3: { // Resume System.out.println("[jdwp] Thread.Resume tid=" + tid); + invalidateStack(); + invalidateLocals(); if (device != null) try { device.resume(tid); } catch (IOException ignore) {} writeReply(id, 0, empty()); return; } @@ -1309,6 +1313,10 @@ private void fetchStackForThread(long tid) { System.out.println("[jdwp] BP_HIT tid=" + threadId + " methodId=" + methodId + " line=" + line); if (!knownThreads.contains(threadId)) knownThreads.add(threadId); lastSuspendedThread = threadId; + // The thread just paused at a new location — drop any stale cache + // from a previous suspension and re-fetch. + invalidateStack(); + invalidateLocals(); // Eagerly ask the device for stack so the IDE's subsequent Frames // query returns something useful. if (device != null) try { device.getStack(threadId); } catch (IOException ignore) {} @@ -1339,6 +1347,13 @@ private void fetchStackForThread(long tid) { @Override public void onStepComplete(long threadId, int methodId, int line) { Integer rid = stepRequests.remove(threadId); + // Drop any cached stack from before the step landed. Without this + // the IDE's Frames request after STEP_COMPLETE hits the cache and + // re-uses the BP_HIT stack — Frames panel sticks on the previous + // line, double-click jumps to the previous line, evaluation runs + // against a dead frame. + invalidateStack(); + invalidateLocals(); System.out.println("[jdwp] STEP_COMPLETE tid=" + threadId + " methodId=" + methodId + " line=" + line + " rid=" + rid); try { SymbolTable.MethodInfo m = symbols.methodById(methodId); @@ -1369,6 +1384,29 @@ private void fetchStackForThread(long tid) { } } + /** + * Drop the cached stack so the next fetchStackForThread round-trips + * to the device. Called whenever the thread state changes under us: + * a new suspend (BP_HIT / STEP_COMPLETE) or a resume (VM.Resume, + * Thread.Resume). + */ + private void invalidateStack() { + synchronized (stackLock) { + lastStackMids = null; + lastStackLines = null; + stackThreadId = -1; + } + } + + /** Drop the cached locals — same lifecycle as invalidateStack. */ + private void invalidateLocals() { + synchronized (localsLock) { + lastLocalsSlots = null; + lastLocalsTypes = null; + lastLocalsValues = null; + } + } + @Override public void onLocals(int[] slots, byte[] typeCodes, long[] values) { synchronized (localsLock) { lastLocalsSlots = slots; From 4fcb521a8745dcd90197a967c16aba25cdff36f0 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Fri, 22 May 2026 21:51:02 +0300 Subject: [PATCH 11/13] Array inspection: ArrayReference.Length / GetValues + array type tag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ArrayList's `elementData` (and any other Object[] / int[] / etc.) was showing as a single ref in the Variables view with no children — the proxy stubbed both ArrayReference commands with NOT_IMPLEMENTED, so IntelliJ couldn't expand the array. Device side (cn1_debugger.m): - CMD_GET_ARRAY_LENGTH reads ((JAVA_ARRAY)obj)->length. - CMD_GET_ARRAY_VALUES walks ->data with element width chosen from primitiveSize + the element class's clsName (so byte/boolean arrays don't get conflated, neither do int/float and long/double). Element bytes go on the wire as packed big-endian — same layout JDWP expects for ArrayRegion primitive payloads. - CMD_GET_OBJECT_CLASS now reports the class struct's `isArray` flag as a trailing byte. Older proxies that only read 4 bytes still work. Wire protocol additions: CMD_GET_ARRAY_LENGTH (0x0F), CMD_GET_ARRAY_VALUES (0x10), EVT_ARRAY_LENGTH (0x8E), EVT_ARRAY_VALUES (0x8F). Proxy: - JdwpServer.handleArray wires JDWP ArrayReference.Length (cmd 1) and GetValues (cmd 2) to the device commands above. For object arrays the GetValues reply tags each element 'L' inline (JDWP's ArrayRegion encoding for non-primitive arrays); for primitives we forward the packed bytes verbatim. - DeviceConnection.onObjectClass now carries an isArray flag from the device. JdwpServer's ObjectReference.ReferenceType uses TYPE_TAG_ARRAY (3) for array instances so IntelliJ knows to issue ArrayReference commands rather than treating the object as a class instance and giving up. Verified: dropping a BP in user code, expanding an ArrayList in the Variables view now shows `elementData[0..size-1]` with values, not just an opaque object reference. --- Ports/iOSPort/nativeSources/cn1_debugger.m | 156 +++++++++++++++++- .../debug/proxy/DeviceConnection.java | 41 ++++- .../com/codename1/debug/proxy/JdwpServer.java | 124 +++++++++++++- .../debug/proxy/LoggingListener.java | 10 +- .../codename1/debug/proxy/WireProtocol.java | 4 + 5 files changed, 326 insertions(+), 9 deletions(-) diff --git a/Ports/iOSPort/nativeSources/cn1_debugger.m b/Ports/iOSPort/nativeSources/cn1_debugger.m index 20ff77a4c4..d87cfc8f29 100644 --- a/Ports/iOSPort/nativeSources/cn1_debugger.m +++ b/Ports/iOSPort/nativeSources/cn1_debugger.m @@ -54,6 +54,8 @@ #define CMD_GET_OBJECT_CLASS 0x0C #define CMD_GET_OBJECT_FIELDS 0x0D #define CMD_INVOKE_METHOD 0x0E +#define CMD_GET_ARRAY_LENGTH 0x0F +#define CMD_GET_ARRAY_VALUES 0x10 // Events (device -> proxy) #define EVT_HELLO 0x80 @@ -70,6 +72,8 @@ #define EVT_STDOUT_LINE 0x8B #define EVT_STDERR_LINE 0x8C #define EVT_INVOKE_RESULT 0x8D +#define EVT_ARRAY_LENGTH 0x8E +#define EVT_ARRAY_VALUES 0x8F // java.lang.String's clazz struct is emitted by the translator; we reference // it by symbol so cn1_debugger.m doesn't depend on the generated @@ -862,14 +866,20 @@ static int handleCommand(uint8_t cmd, const uint8_t* payload, uint32_t len) { memcpy(&lo, payload + 4, 4); uint64_t ptr = ((uint64_t)ntohl(hi) << 32) | (uint32_t)ntohl(lo); int classId = -1; + uint8_t isArray = 0; JAVA_OBJECT obj = (JAVA_OBJECT)(uintptr_t)ptr; if (obj != JAVA_NULL && obj->__codenameOneParentClsReference != NULL) { - classId = obj->__codenameOneParentClsReference->classId; + struct clazz* cls = obj->__codenameOneParentClsReference; + classId = cls->classId; + isArray = cls->isArray ? 1 : 0; } - uint8_t reply[4]; + // Reply: classId(4) + isArray(1). Older proxies that only read + // 4 bytes still work. + uint8_t reply[5]; uint32_t cidBE = htonl((uint32_t)classId); memcpy(reply, &cidBE, 4); - sendEvent(EVT_OBJECT_CLASS, reply, 4); + reply[4] = isArray; + sendEvent(EVT_OBJECT_CLASS, reply, 5); return 0; } case CMD_GET_STRING: { @@ -1057,6 +1067,146 @@ static int handleCommand(uint8_t cmd, const uint8_t* payload, uint32_t len) { sendEvent(EVT_INVOKE_RESULT, reply, 9); return 0; } + case CMD_GET_ARRAY_LENGTH: { + // Payload: objId(8). Reply: EVT_ARRAY_LENGTH with length(4). + if (len < 8) { + uint8_t err[4] = {0,0,0,0}; + sendEvent(EVT_ARRAY_LENGTH, err, 4); + return 0; + } + uint32_t hi, lo; + memcpy(&hi, payload, 4); memcpy(&lo, payload + 4, 4); + uint64_t ptr = ((uint64_t)ntohl(hi) << 32) | (uint32_t)ntohl(lo); + JAVA_OBJECT obj = (JAVA_OBJECT)(uintptr_t)ptr; + int length = 0; + if (obj != JAVA_NULL) { + length = ((JAVA_ARRAY)obj)->length; + } + uint8_t reply[4]; + writeBE32(reply, (uint32_t)length); + sendEvent(EVT_ARRAY_LENGTH, reply, 4); + return 0; + } + case CMD_GET_ARRAY_VALUES: { + // Payload: objId(8) firstIndex(4) count(4) + // Reply: tag(1) + count(4) + values. Element width depends on tag. + if (len < 16) { + uint8_t err[5] = {'L', 0,0,0,0}; + sendEvent(EVT_ARRAY_VALUES, err, 5); + return 0; + } + uint32_t hi, lo; + memcpy(&hi, payload, 4); memcpy(&lo, payload + 4, 4); + uint64_t ptr = ((uint64_t)ntohl(hi) << 32) | (uint32_t)ntohl(lo); + uint32_t fstBE, cntBE; + memcpy(&fstBE, payload + 8, 4); + memcpy(&cntBE, payload + 12, 4); + int firstIdx = (int)ntohl(fstBE); + int reqCount = (int)ntohl(cntBE); + JAVA_OBJECT obj = (JAVA_OBJECT)(uintptr_t)ptr; + if (obj == JAVA_NULL) { + uint8_t err[5] = {'L', 0,0,0,0}; + sendEvent(EVT_ARRAY_VALUES, err, 5); + return 0; + } + JAVA_ARRAY arr = (JAVA_ARRAY)obj; + int arrLen = arr->length; + if (firstIdx < 0) firstIdx = 0; + if (firstIdx > arrLen) firstIdx = arrLen; + int avail = arrLen - firstIdx; + int count = (reqCount < 0 || reqCount > avail) ? avail : reqCount; + + // Derive the JVM type-char for elements. primitiveSize==0 means + // object array; otherwise we use the element class's clsName to + // distinguish among same-width primitives (byte vs boolean, int + // vs float, ...). + char tag = 'L'; + int elemSize = arr->primitiveSize; + struct clazz* arrCls = obj->__codenameOneParentClsReference; + struct clazz* elemCls = arrCls ? arrCls->arrayType : NULL; + if (elemSize == 0) { + tag = 'L'; + } else if (elemCls && elemCls->clsName) { + const char* en = elemCls->clsName; + if (strcmp(en, "int") == 0) tag = 'I'; + else if (strcmp(en, "long") == 0) tag = 'J'; + else if (strcmp(en, "boolean") == 0) tag = 'Z'; + else if (strcmp(en, "byte") == 0) tag = 'B'; + else if (strcmp(en, "char") == 0) tag = 'C'; + else if (strcmp(en, "short") == 0) tag = 'S'; + else if (strcmp(en, "float") == 0) tag = 'F'; + else if (strcmp(en, "double") == 0) tag = 'D'; + else { + // Unknown primitive — fall back to width-based guess. + switch (elemSize) { + case 1: tag = 'B'; break; + case 2: tag = 'S'; break; + case 4: tag = 'I'; break; + case 8: tag = 'J'; break; + default: tag = 'L'; break; + } + } + } else { + switch (elemSize) { + case 1: tag = 'B'; break; + case 2: tag = 'S'; break; + case 4: tag = 'I'; break; + case 8: tag = 'J'; break; + default: tag = 'L'; break; + } + } + + // Element bytes on the wire match the tag's natural width. + int perElem; + switch (tag) { + case 'Z': case 'B': perElem = 1; break; + case 'S': case 'C': perElem = 2; break; + case 'I': case 'F': perElem = 4; break; + case 'J': case 'D': perElem = 8; break; + case 'L': case '[': default: perElem = 8; break; + } + uint32_t sz = 1 + 4 + (uint32_t)count * (uint32_t)perElem; + uint8_t* buf = (uint8_t*)malloc(sz); + if (!buf) { + uint8_t err[5] = {'L', 0,0,0,0}; + sendEvent(EVT_ARRAY_VALUES, err, 5); + return 0; + } + buf[0] = (uint8_t)tag; + writeBE32(buf + 1, (uint32_t)count); + uint8_t* p = buf + 5; + for (int i = 0; i < count; i++) { + int idx = firstIdx + i; + switch (tag) { + case 'Z': p[0] = ((JAVA_BOOLEAN*)arr->data)[idx] & 1; p += 1; break; + case 'B': p[0] = (uint8_t)((JAVA_BYTE*)arr->data)[idx]; p += 1; break; + case 'S': { JAVA_SHORT s = ((JAVA_SHORT*)arr->data)[idx]; + p[0] = (uint8_t)((s >> 8) & 0xff); p[1] = (uint8_t)(s & 0xff); } + p += 2; break; + case 'C': { JAVA_CHAR c = ((JAVA_CHAR*)arr->data)[idx]; + p[0] = (uint8_t)((c >> 8) & 0xff); p[1] = (uint8_t)(c & 0xff); } + p += 2; break; + case 'I': writeBE32(p, (uint32_t)((JAVA_INT*)arr->data)[idx]); p += 4; break; + case 'F': { JAVA_FLOAT f = ((JAVA_FLOAT*)arr->data)[idx]; + uint32_t fb; memcpy(&fb, &f, 4); + writeBE32(p, fb); } + p += 4; break; + case 'J': writeBE64(p, (uint64_t)((JAVA_LONG*)arr->data)[idx]); p += 8; break; + case 'D': { JAVA_DOUBLE d = ((JAVA_DOUBLE*)arr->data)[idx]; + uint64_t db; memcpy(&db, &d, 8); + writeBE64(p, db); } + p += 8; break; + case 'L': case '[': default: { + JAVA_OBJECT v = ((JAVA_OBJECT*)arr->data)[idx]; + writeBE64(p, (uint64_t)(uintptr_t)v); + p += 8; break; + } + } + } + sendEvent(EVT_ARRAY_VALUES, buf, sz); + free(buf); + return 0; + } case CMD_GET_THREADS: case CMD_SUSPEND: // Minimal viable: reply empty so the proxy doesn't hang. diff --git a/maven/cn1-debug-proxy/src/main/java/com/codename1/debug/proxy/DeviceConnection.java b/maven/cn1-debug-proxy/src/main/java/com/codename1/debug/proxy/DeviceConnection.java index df5961ab06..e8ca3afbab 100644 --- a/maven/cn1-debug-proxy/src/main/java/com/codename1/debug/proxy/DeviceConnection.java +++ b/maven/cn1-debug-proxy/src/main/java/com/codename1/debug/proxy/DeviceConnection.java @@ -40,9 +40,16 @@ public interface DeviceListener { void onLocals(int[] slots, byte[] typeCodes, long[] values); void onVmDeath(); void onStringValue(String value); - void onObjectClass(int classId); + void onObjectClass(int classId, boolean isArray); void onObjectFields(byte[] typeCodes, long[] values); void onInvokeResult(byte type, long value); + void onArrayLength(int length); + /** + * Raw array values from the device. {@code tag} is the JVM type-char + * shared by every element, {@code rawBytes} is the wire payload with + * each element packed in big-endian. + */ + void onArrayValues(byte tag, int count, byte[] rawBytes); void onReplyStatus(); void onStdoutLine(String line); void onStderrLine(String line); @@ -158,7 +165,9 @@ private void dispatch(int code, byte[] p) { } case WireProtocol.EVT_OBJECT_CLASS: { int cid = p.length >= 4 ? readInt(p, 0) : -1; - listener.onObjectClass(cid); + // 5th byte is isArray flag; older devices omit it. + boolean isArr = p.length >= 5 && p[4] != 0; + listener.onObjectClass(cid, isArr); return; } case WireProtocol.EVT_OBJECT_FIELDS: { @@ -174,6 +183,20 @@ private void dispatch(int code, byte[] p) { listener.onObjectFields(types, values); return; } + case WireProtocol.EVT_ARRAY_LENGTH: { + int len = p.length >= 4 ? readInt(p, 0) : 0; + listener.onArrayLength(len); + return; + } + case WireProtocol.EVT_ARRAY_VALUES: { + if (p.length < 5) { listener.onUnknownEvent(code, p); return; } + byte tag = p[0]; + int n = readInt(p, 1); + byte[] raw = new byte[p.length - 5]; + System.arraycopy(p, 5, raw, 0, raw.length); + listener.onArrayValues(tag, n, raw); + return; + } case WireProtocol.EVT_INVOKE_RESULT: { // Payload: type(1) + value(8). Value packing depends on type: // I/F/Z/B/S/C use the int slot, J/D the long slot, @@ -283,6 +306,20 @@ public void getString(long objPtr) throws IOException { * unpacks it into the typed C parameter the underlying function * expects. */ + public void getArrayLength(long arrayPtr) throws IOException { + byte[] p = new byte[8]; + writeLong(p, 0, arrayPtr); + sendCommand(WireProtocol.CMD_GET_ARRAY_LENGTH, p); + } + + public void getArrayValues(long arrayPtr, int firstIndex, int count) throws IOException { + byte[] p = new byte[16]; + writeLong(p, 0, arrayPtr); + writeInt(p, 8, firstIndex); + writeInt(p, 12, count); + sendCommand(WireProtocol.CMD_GET_ARRAY_VALUES, p); + } + public void invokeMethod(long threadId, int methodId, long thisObj, byte[] argTypes, long[] argValues) throws IOException { int n = argTypes.length; diff --git a/maven/cn1-debug-proxy/src/main/java/com/codename1/debug/proxy/JdwpServer.java b/maven/cn1-debug-proxy/src/main/java/com/codename1/debug/proxy/JdwpServer.java index 67ad65251b..1ad4790746 100644 --- a/maven/cn1-debug-proxy/src/main/java/com/codename1/debug/proxy/JdwpServer.java +++ b/maven/cn1-debug-proxy/src/main/java/com/codename1/debug/proxy/JdwpServer.java @@ -69,6 +69,8 @@ public final class JdwpServer implements DeviceConnection.DeviceListener { // refTypeTag private static final int TYPE_TAG_CLASS = 1; + private static final int TYPE_TAG_INTERFACE = 2; + private static final int TYPE_TAG_ARRAY = 3; // SuspendPolicy private static final int SP_NONE = 0; @@ -315,7 +317,7 @@ private void dispatchCommand(int id, int cmdSet, int cmd, byte[] p) throws IOExc case CS_OBJECT_REFERENCE: handleObject(id, cmd, p); return; case CS_CLASS_TYPE: handleClassType(id, cmd, p); return; case CS_STRING_REFERENCE: handleString(id, cmd, p); return; - case CS_ARRAY_REFERENCE: + case CS_ARRAY_REFERENCE: handleArray(id, cmd, p); return; case CS_CLASS_LOADER_REF: case CS_CLASS_OBJECT_REF: // Reply with NOT_IMPLEMENTED (100) so jdb falls back gracefully. @@ -1058,12 +1060,14 @@ private void handleObject(int id, int cmd, byte[] p) throws IOException { switch (cmd) { case 1: { // ReferenceType — get class int classId = blockingGetObjectClass(objectId); + boolean isArr; + synchronized (objectClassLock) { isArr = lastObjectIsArray; } Buf b = new Buf(); if (classId < 0) { b.writeByte(TYPE_TAG_CLASS); b.writeLong(0); } else { - b.writeByte(TYPE_TAG_CLASS); + b.writeByte(isArr ? TYPE_TAG_ARRAY : TYPE_TAG_CLASS); b.writeLong(toJdwpRef(classId)); } writeReply(id, 0, b.bytes()); @@ -1159,17 +1163,114 @@ private void handleString(int id, int cmd, byte[] p) throws IOException { } } + private void handleArray(int id, int cmd, byte[] p) throws IOException { + long objectId = readLong(p, 0); + switch (cmd) { + case 1: { // Length + int length = blockingGetArrayLength(objectId); + Buf b = new Buf(); + b.writeInt(length < 0 ? 0 : length); + writeReply(id, 0, b.bytes()); + return; + } + case 2: { // GetValues + int first = readInt(p, 8); + int count = readInt(p, 12); + if (!blockingGetArrayValues(objectId, first, count)) { + // ArrayRegion: tag byte 'L', count 0 — well-formed empty. + Buf b = new Buf(); + b.writeByte('L'); + b.writeInt(0); + writeReply(id, 0, b.bytes()); + return; + } + byte tag = lastArrayTag; + int n = lastArrayCount; + byte[] raw = lastArrayBytes != null ? lastArrayBytes : new byte[0]; + Buf b = new Buf(); + b.writeByte(tag & 0xff); + b.writeInt(n); + // JDWP ArrayRegion encoding: primitive arrays carry the + // packed primitive values directly; object arrays carry a + // tagged value (1-byte tag + objectID) per element. + if (tag == 'L' || tag == '[') { + // Each element on the wire is 8 bytes (object reference). + int per = 8; + int off = 0; + for (int i = 0; i < n; i++) { + b.writeByte('L'); + // raw bytes are already big-endian + for (int k = 0; k < per; k++) { + b.writeByte(raw[off + k] & 0xff); + } + off += per; + } + } else { + // Primitive: just append the raw bytes — no per-element + // tag, just count * width. + for (byte by : raw) b.writeByte(by & 0xff); + } + writeReply(id, 0, b.bytes()); + return; + } + default: + writeReply(id, 100, empty()); + } + } + // -------- Synchronous request/reply against the device ---------------- + private final Object arrayLock = new Object(); + private boolean pendingArrayLength = false; + private int lastArrayLength = 0; + private boolean pendingArrayValues = false; + private byte lastArrayTag = 'L'; + private int lastArrayCount = 0; + private byte[] lastArrayBytes = null; + + private int blockingGetArrayLength(long objectId) { + if (device == null) return -1; + synchronized (arrayLock) { + pendingArrayLength = true; + lastArrayLength = 0; + try { device.getArrayLength(objectId); } + catch (IOException io) { pendingArrayLength = false; return -1; } + long deadline = System.currentTimeMillis() + 2000; + while (pendingArrayLength && System.currentTimeMillis() < deadline) { + try { arrayLock.wait(deadline - System.currentTimeMillis()); } + catch (InterruptedException ie) { break; } + } + return lastArrayLength; + } + } + + private boolean blockingGetArrayValues(long objectId, int firstIndex, int count) { + if (device == null) return false; + synchronized (arrayLock) { + pendingArrayValues = true; + lastArrayBytes = null; + try { device.getArrayValues(objectId, firstIndex, count); } + catch (IOException io) { pendingArrayValues = false; return false; } + long deadline = System.currentTimeMillis() + 2000; + while (pendingArrayValues && System.currentTimeMillis() < deadline) { + try { arrayLock.wait(deadline - System.currentTimeMillis()); } + catch (InterruptedException ie) { break; } + } + return lastArrayBytes != null; + } + } + private final Object objectClassLock = new Object(); private boolean pendingObjectClass = false; private int lastObjectClass = -1; + private boolean lastObjectIsArray = false; private int blockingGetObjectClass(long objectId) { if (device == null) return -1; synchronized (objectClassLock) { pendingObjectClass = true; lastObjectClass = -1; + lastObjectIsArray = false; try { device.getObjectClass(objectId); } catch (IOException io) { return -1; } long deadline = System.currentTimeMillis() + 2000; while (pendingObjectClass && System.currentTimeMillis() < deadline) { @@ -1435,9 +1536,10 @@ private void invalidateLocals() { stringLock.notifyAll(); } } - @Override public void onObjectClass(int classId) { + @Override public void onObjectClass(int classId, boolean isArray) { synchronized (objectClassLock) { lastObjectClass = classId; + lastObjectIsArray = isArray; pendingObjectClass = false; objectClassLock.notifyAll(); } @@ -1458,6 +1560,22 @@ private void invalidateLocals() { invokeLock.notifyAll(); } } + @Override public void onArrayLength(int length) { + synchronized (arrayLock) { + lastArrayLength = length; + pendingArrayLength = false; + arrayLock.notifyAll(); + } + } + @Override public void onArrayValues(byte tag, int count, byte[] rawBytes) { + synchronized (arrayLock) { + lastArrayTag = tag; + lastArrayCount = count; + lastArrayBytes = rawBytes; + pendingArrayValues = false; + arrayLock.notifyAll(); + } + } @Override public void onReplyStatus() {} @Override public void onStdoutLine(String line) { // Surface device prints in whatever console the proxy is running in diff --git a/maven/cn1-debug-proxy/src/main/java/com/codename1/debug/proxy/LoggingListener.java b/maven/cn1-debug-proxy/src/main/java/com/codename1/debug/proxy/LoggingListener.java index 177b818b50..ef02a6c666 100644 --- a/maven/cn1-debug-proxy/src/main/java/com/codename1/debug/proxy/LoggingListener.java +++ b/maven/cn1-debug-proxy/src/main/java/com/codename1/debug/proxy/LoggingListener.java @@ -54,7 +54,9 @@ public LoggingListener(SymbolTable symbols) { @Override public void onVmDeath() { System.out.println("[event] VM_DEATH"); } @Override public void onStringValue(String value) { System.out.println("[event] STRING_VALUE=" + value); } - @Override public void onObjectClass(int classId) { System.out.println("[event] OBJECT_CLASS=" + classId); } + @Override public void onObjectClass(int classId, boolean isArray) { + System.out.println("[event] OBJECT_CLASS=" + classId + (isArray ? " (array)" : "")); + } @Override public void onObjectFields(byte[] typeCodes, long[] values) { System.out.println("[event] OBJECT_FIELDS count=" + typeCodes.length); for (int i = 0; i < typeCodes.length; i++) { @@ -64,6 +66,12 @@ public LoggingListener(SymbolTable symbols) { @Override public void onInvokeResult(byte type, long value) { System.out.println("[event] INVOKE_RESULT type='" + (char) type + "' value=" + formatValue(type, value)); } + @Override public void onArrayLength(int length) { + System.out.println("[event] ARRAY_LENGTH=" + length); + } + @Override public void onArrayValues(byte tag, int count, byte[] rawBytes) { + System.out.println("[event] ARRAY_VALUES tag='" + (char) tag + "' count=" + count + " bytes=" + rawBytes.length); + } @Override public void onReplyStatus() { System.out.println("[event] REPLY_STATUS"); } @Override public void onStdoutLine(String line) { System.out.println("[device] " + line); } @Override public void onStderrLine(String line) { System.err.println("[device] " + line); } diff --git a/maven/cn1-debug-proxy/src/main/java/com/codename1/debug/proxy/WireProtocol.java b/maven/cn1-debug-proxy/src/main/java/com/codename1/debug/proxy/WireProtocol.java index 4479363ca0..4d7fb171fc 100644 --- a/maven/cn1-debug-proxy/src/main/java/com/codename1/debug/proxy/WireProtocol.java +++ b/maven/cn1-debug-proxy/src/main/java/com/codename1/debug/proxy/WireProtocol.java @@ -36,6 +36,8 @@ private WireProtocol() {} public static final int CMD_GET_OBJECT_CLASS = 0x0C; public static final int CMD_GET_OBJECT_FIELDS = 0x0D; public static final int CMD_INVOKE_METHOD = 0x0E; + public static final int CMD_GET_ARRAY_LENGTH = 0x0F; + public static final int CMD_GET_ARRAY_VALUES = 0x10; public static final int EVT_HELLO = 0x80; public static final int EVT_THREAD_LIST = 0x81; @@ -51,6 +53,8 @@ private WireProtocol() {} public static final int EVT_STDOUT_LINE = 0x8B; public static final int EVT_STDERR_LINE = 0x8C; public static final int EVT_INVOKE_RESULT = 0x8D; + public static final int EVT_ARRAY_LENGTH = 0x8E; + public static final int EVT_ARRAY_VALUES = 0x8F; public static final int STEP_INTO = 0; public static final int STEP_OVER = 1; From 9b848477f1f66cb2066cdac07ac028850201204b Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Fri, 22 May 2026 21:58:06 +0300 Subject: [PATCH 12/13] Docs: reflect array + invoke-method support, fix vale issues Update the "What works today" list: - Add a bullet for array inspection (length + per-index values for Object[] and primitive arrays, including ArrayList.elementData drilldown). - Expand the method-invocation bullet to mention the Display.getInstance().getCurrent().getTitle() chain that motivated the implementation, plus the catch-all try block that keeps an uncaught throw from tearing down the session. Vale: move punctuation inside the quoted phrase and rephrase the first-person plural in the troubleshooting section. --- .../On-Device-Debugging.asciidoc | 26 ++++++++++++++----- 1 file changed, 20 insertions(+), 6 deletions(-) diff --git a/docs/developer-guide/On-Device-Debugging.asciidoc b/docs/developer-guide/On-Device-Debugging.asciidoc index 5bf1730eb9..7b23bba214 100644 --- a/docs/developer-guide/On-Device-Debugging.asciidoc +++ b/docs/developer-guide/On-Device-Debugging.asciidoc @@ -139,6 +139,20 @@ Two workarounds: walks the ParparVM struct layout to read instance fields directly at known offsets, so `dump this` / "Variables" shows the same fields you'd see in a simulator debug session. +* Array inspection — `Object[]`, `int[]`, `byte[]`, etc. report + their length and per-index values to the IDE so expanding e.g. + `ArrayList.elementData` shows the actual element references + rather than an opaque reference. Primitive element types + round-trip with their JVM type tag. +* Method invocation — the IDE's "Evaluate Expression" / + `print` command can call instance and static methods on objects + in scope. The classic chain + `Display.getInstance().getCurrent().getTitle()` evaluates to the + current form's title string, so `myForm.getComponentCount()` and + similar accessor calls work too. The call runs on the suspended + Java thread inside a catch-all try block, so uncaught throws + round-trip back as an exception reference rather than tearing + down the debugger. * Native device output — `System.out.println`, `Log.p`, and `printf` / `NSLog` from native code are surfaced in the proxy's console (and the IDE's debug console when the proxy is launched @@ -199,9 +213,9 @@ in the IDE editor when stepping into framework code. compile with debug info by default; if a variable shows up as `v1`, `v2`, ... that's because that particular class was compiled without debug info. -* *Static field reads* fall back to "not supported". Instance field - reads are fully wired; static-field support requires an additional - per-class static table that hasn't been emitted yet. +* *Static field reads* are unsupported. Instance field reads are + fully wired; static-field support requires an additional per-class + static table that hasn't been emitted yet. * *NSLog on a real device* (as opposed to the simulator) may not reach the `[device]` console. NSLog on iOS routes to `os_log` which doesn't always mirror to `stderr`. `printf` / `fprintf` / @@ -249,9 +263,9 @@ firewall is blocking the port. Verify: This means an unsupported JDWP command was invoked. The session itself survives; the most common cause is the IDE asking for -something we explicitly stub (see "Known limitations" above). If -you hit a less obvious case, the proxy logs the unknown command -code — file an issue with that code attached. +something the proxy explicitly stubs (see "Known limitations" +above). If you hit a less obvious case, the proxy logs the unknown +command code — file an issue with that code attached. ==== The app launches but the breakpoint never fires From 499707187f92b12150d21a6adf6e0b53220a17f2 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Fri, 22 May 2026 22:50:20 +0300 Subject: [PATCH 13/13] Docs: drive developer-guide LanguageTool count to zero on this branch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After merging master (which promoted LanguageTool to a hard quality gate via #5007) the developer guide had 7 grammar-checker matches attributable to this branch's chapter plus 1 pre-existing match elsewhere. This drives the count back to 0. Chapter-local fixes (docs/developer-guide/On-Device-Debugging.asciidoc): - Replace "source dir" with "source directory" so LanguageTool's morfologik English dictionary stops flagging the abbreviation. - Rephrase the "Watching expressions follow the same rule" sentence so QUESTION_MARK no longer fires (LanguageTool reads the "ing" opening as a rhetorical fragment expecting a question mark). - Rephrase the "What doesn't work:" lead so the wh-word doesn't trip QUESTION_MARK on the surrounding sentence. Accept-list additions (docs/developer-guide/languagetool-accept.txt): - jdb (the JDK command-line debugger), - loopback (the networking term), - rethrow / rethrows (standard exception-handling vocabulary). These are well-known technical terms LanguageTool's default dictionary doesn't recognise but appear unaltered in any reasonable developer-facing prose. Pre-existing match (docs/developer-guide/Maven-Getting-Started.adoc): Replace "(aka X. aka Y)" with "(also known as X or Y)" — the period between the two "aka"s made LanguageTool flag the second "aka" as a sentence-start lowercase letter. Master's docs job is PR-trigger-only so this never showed up there; it surfaced here because the merge brought in the gate. --- docs/developer-guide/Maven-Getting-Started.adoc | 2 +- docs/developer-guide/On-Device-Debugging.asciidoc | 8 ++++---- docs/developer-guide/languagetool-accept.txt | 9 +++++++++ 3 files changed, 14 insertions(+), 5 deletions(-) diff --git a/docs/developer-guide/Maven-Getting-Started.adoc b/docs/developer-guide/Maven-Getting-Started.adoc index 3ede19c356..b4fc4c21a0 100644 --- a/docs/developer-guide/Maven-Getting-Started.adoc +++ b/docs/developer-guide/Maven-Getting-Started.adoc @@ -352,7 +352,7 @@ If the compliance check fails (that is, the app uses unsupported APIs), the buil [#managing-addons-in-control-center] ==== Managing Add-Ons in control center -As mentioned throughout this guide, the best place to find and install add-ons for your project is in the Codename One Control Center (aka Codename One Preferences. aka Codename One Settings). See <>. +As mentioned throughout this guide, the best place to find and install add-ons for your project is in the Codename One Control Center (also known as Codename One Preferences or Codename One Settings). See <>. From the dashboard, select "Advanced Settings" > "Extensions" in the navigation menu on the left as shown below: diff --git a/docs/developer-guide/On-Device-Debugging.asciidoc b/docs/developer-guide/On-Device-Debugging.asciidoc index 7b23bba214..a812950090 100644 --- a/docs/developer-guide/On-Device-Debugging.asciidoc +++ b/docs/developer-guide/On-Device-Debugging.asciidoc @@ -169,7 +169,7 @@ for framework classes, but the IDE still needs to find the actual `CodenameOne/src` as a source root in your IDE's debug configuration. In IntelliJ: *Run → Edit Configurations… → Remote JVM Debug → Configuration → Source roots → +*. -* *jdb command line.* Pass the framework source dir on the +* *jdb command line.* Pass the framework source directory on the `-sourcepath`: + [source,bash] @@ -194,11 +194,11 @@ in the IDE editor when stepping into framework code. jdb's `print myForm.getTitle()` and IntelliJ's "Evaluate Expression" pop-up work for the chains you actually want (`Display.getInstance().getCurrent().getTitle()` is the canonical - test). What doesn't work: methods on `java.io.*`, `java.net.*`, - `java.nio.*`, and `com.codename1.impl.*` — those classes are linked + test). Methods on `java.io.*`, `java.net.*`, `java.nio.*`, and + `com.codename1.impl.*` are unsupported — those classes are linked against hand-written native code that has fallen out of sync with the generic invoke-thunk calling convention, so the translator - skips them. Watching expressions follow the same rule. + skips them. The same limit applies to watch expressions. * *Constructor invocation* isn't supported. The debugger can call existing instance methods but not `new Foo(...)`. * *Hot-swap* isn't supported. Code changes require a rebuild and diff --git a/docs/developer-guide/languagetool-accept.txt b/docs/developer-guide/languagetool-accept.txt index 1107b0591c..86b6ee1870 100644 --- a/docs/developer-guide/languagetool-accept.txt +++ b/docs/developer-guide/languagetool-accept.txt @@ -482,3 +482,12 @@ googleservice-info nestoria twilio xapplication + +# ----------------------------------------------------------------------------- +# Java tooling / networking terms — standard names LanguageTool's English +# dictionary doesn't recognise. +# ----------------------------------------------------------------------------- +jdb +loopback +rethrow +rethrows