From 4ac2d15a36b98069b973853991b62d1444cc873d Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Fri, 18 Sep 2026 22:11:16 +0300 Subject: [PATCH 1/8] Teach the generated agent skill the device loops, and give the archetype the Initializr's layout Issue #5699 reports that a fresh Java 17 project tells an agent nothing about the on-device Maven goals or about driving a build on a phone, and that the Maven archetype and the web Initializr do not generate the same thing. Both halves are real; the issue's first request (an MCP reference) is not - it was filed against ac826b1, and references/mcp-agent-control.md has since landed, so that part is a pointer rather than a new file. references/on-device-debugging.md is the new reference. It covers the Android flow (android.onDeviceDebug, buildAndroidOnDeviceDebug or a local Gradle assembleDebug, android-on-device-debugging, JDWP on 5005, and the flags that matter), the iOS flow (the four ios.onDeviceDebug hints, proxyHost being 127.0.0.1 only for the native simulator, the app dialling the proxy on 55333 while the debugger attaches on 8000, and the invocation limits that make an expression refuse to evaluate), and how to reach the device's MCP port: adb forward on Android, nothing at all on the iOS simulator, a usbmux relay on a physical iPhone. The last one is stated as the route rather than as something we ship - there is no Codename One goal that tunnels it. Discovery is the other half. The Initializr writes AGENTS.md, the skill under .agent-skills/codename-one/ and a thin stub at .claude/skills/codename-one/, while the archetype wrote only the full skill under .claude/ - so an archetype-generated project had no root pointer and no vendor-neutral copy, and an agent that does not know Claude Code's directory layout never found the skill at all. The archetype now stages all three, from the same files, and strips all three for Java 8 the way it already stripped .claude/. AGENTS.md and the Claude stub move out of GeneratorModel's string constants and into files precisely because the archetype now needs them too: a second copy is how the two generators drifted apart in the first place. They sit flat at the root of src/main/resources for the same reason skill/ is repackaged into skill.zip - Codename One's classloader rejects nested resource directories. Verified end to end: the real Initializr ZIPs (scripts/tests/generate-initializr-fixtures.py) carry the new reference and the extended AGENTS.md, a project generated with -DjavaVersion=17 from the rebuilt archetype has all three, and one generated with -DjavaVersion=8 has none of them. Co-Authored-By: Claude Opus 5 (1M context) --- maven/cn1app-archetype/pom.xml | 70 ++++++++-- .../META-INF/archetype-post-generate.groovy | 18 ++- .../META-INF/maven/archetype-metadata.xml | 22 ++- .../Cn1AppArchetypeCertificateWizardTest.java | 60 ++++++++ .../initializr/model/GeneratorModel.java | 62 ++------ .../common/src/main/resources/AGENTS.md | 29 ++++ .../main/resources/agent-skill-claude-stub.md | 18 +++ .../common/src/main/resources/skill/SKILL.md | 1 + .../resources/skill/references/debugging.md | 2 +- .../skill/references/mcp-agent-control.md | 6 + .../skill/references/on-device-debugging.md | 132 ++++++++++++++++++ .../model/GeneratorModelMatrixTest.java | 5 + 12 files changed, 355 insertions(+), 70 deletions(-) create mode 100644 scripts/initializr/common/src/main/resources/AGENTS.md create mode 100644 scripts/initializr/common/src/main/resources/agent-skill-claude-stub.md create mode 100644 scripts/initializr/common/src/main/resources/skill/references/on-device-debugging.md diff --git a/maven/cn1app-archetype/pom.xml b/maven/cn1app-archetype/pom.xml index f68297c9adc..d29ca680314 100644 --- a/maven/cn1app-archetype/pom.xml +++ b/maven/cn1app-archetype/pom.xml @@ -37,15 +37,27 @@ @@ -53,9 +65,17 @@ ${project.basedir}/../../scripts/initializr/common/src/main/resources/skill - archetype-resources/.claude/skills/codename-one + archetype-resources/.agent-skills/codename-one false + + ${project.basedir}/../../scripts/initializr/common/src/main/resources + archetype-resources + false + + AGENTS.md + + @@ -78,5 +98,35 @@ + + + + + org.apache.maven.plugins + maven-antrun-plugin + + + stage-claude-skill-stub + process-resources + + run + + + + + + + + + + diff --git a/maven/cn1app-archetype/src/main/resources/META-INF/archetype-post-generate.groovy b/maven/cn1app-archetype/src/main/resources/META-INF/archetype-post-generate.groovy index 8f215569329..82ab2e07087 100644 --- a/maven/cn1app-archetype/src/main/resources/META-INF/archetype-post-generate.groovy +++ b/maven/cn1app-archetype/src/main/resources/META-INF/archetype-post-generate.groovy @@ -120,9 +120,13 @@ def pickJavaVersionFromCurrentJvm() { /** * Apply the Java-version-specific transforms that the initializr does on its * server-rendered templates: - * - Java 17 keeps .claude/skills/codename-one/** (the Codename One authoring skill) - * - Java 8 strips .claude/ so older projects don't suddenly grow an AI-agent - * skill they never opted into. + * - Java 17 keeps the Codename One authoring skill in the same three-part + * layout the initializr generates: AGENTS.md (vendor-neutral root pointer), + * .agent-skills/codename-one/** (the skill), and .claude/skills/codename-one/ + * SKILL.md (a thin stub redirecting to it) + * - Java 8 strips all three so older projects don't suddenly grow an AI-agent + * skill they never opted into. The skill's guidance is Java 17 anyway (var, + * records, text blocks, single-file source mode in tools/). * * The win/ module is the native win32 target and ships for every Java version * (only the long-retired UWP module that previously lived under win/ used to be @@ -134,9 +138,11 @@ def pickJavaVersionFromCurrentJvm() { */ def applyJavaVersionTransforms(rootDir, rootPom, resolvedJava) { if (resolvedJava != "17") { - def claudeDir = new java.io.File(rootDir, ".claude") - if (claudeDir.exists()) { - deleteRecursively(claudeDir) + [".claude", ".agent-skills", "AGENTS.md"].each { name -> + def skillPath = new java.io.File(rootDir, name) + if (skillPath.exists()) { + deleteRecursively(skillPath) + } } } setIntellijLanguageLevel(rootDir, resolvedJava) diff --git a/maven/cn1app-archetype/src/main/resources/META-INF/maven/archetype-metadata.xml b/maven/cn1app-archetype/src/main/resources/META-INF/maven/archetype-metadata.xml index 4249c1353e9..9fb192cc5a0 100644 --- a/maven/cn1app-archetype/src/main/resources/META-INF/maven/archetype-metadata.xml +++ b/maven/cn1app-archetype/src/main/resources/META-INF/maven/archetype-metadata.xml @@ -55,14 +55,24 @@ + + .agent-skills + + ** + + .claude @@ -82,6 +92,8 @@ .gitignore + + AGENTS.md *.sh *.bat *.adoc diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/maven/Cn1AppArchetypeCertificateWizardTest.java b/maven/codenameone-maven-plugin/src/test/java/com/codename1/maven/Cn1AppArchetypeCertificateWizardTest.java index 546a972a5fc..7eb1c605b8c 100644 --- a/maven/codenameone-maven-plugin/src/test/java/com/codename1/maven/Cn1AppArchetypeCertificateWizardTest.java +++ b/maven/codenameone-maven-plugin/src/test/java/com/codename1/maven/Cn1AppArchetypeCertificateWizardTest.java @@ -117,6 +117,66 @@ void generatedRunConfigurationsUseTheRegisteredGoalPrefix() throws Exception { } } + /** + * A generated Java 17 project must hand an agent the skill through all three + * discovery conventions, and through the same paths the Initializr uses: + * {@code AGENTS.md} at the root, the skill itself under + * {@code .agent-skills/codename-one/}, and a thin redirect stub at + * {@code .claude/skills/codename-one/SKILL.md}. + * + * This module shipped the full skill under {@code .claude/} alone (issue #5699), + * which left an archetype-generated project with no root pointer and no + * vendor-neutral copy -- so Codex and anything else that does not happen to know + * Claude Code's directory layout never found it, while the identical project + * downloaded from the Initializr was fine. + * + * All three come from one source directory, staged into the archetype JAR at build + * time, so there is nothing under archetype-resources/ to read here. What is worth + * pinning is that the wiring still names those paths, and that the two shared files + * are still where the pom expects them. + */ + @Test + void agentSkillIsGeneratedInTheSameLayoutAsTheInitializr() throws Exception { + File initializrResources = + new File("../../scripts/initializr/common/src/main/resources"); + assertTrue(new File(initializrResources, "AGENTS.md").isFile(), + "the archetype pom stages AGENTS.md from " + initializrResources); + assertTrue(new File(initializrResources, "agent-skill-claude-stub.md").isFile(), + "the archetype pom stages the Claude stub from " + initializrResources); + assertTrue(new File(initializrResources, "skill/SKILL.md").isFile(), + "the archetype pom stages the skill itself from " + initializrResources); + + String pom = archetypeFile("pom.xml"); + assertTrue(pom.contains("archetype-resources/.agent-skills/codename-one"), + "the skill must be staged under .agent-skills/, not only under .claude/"); + assertTrue(pom.contains("AGENTS.md"), + "the root AGENTS.md pointer must be staged into the archetype"); + assertTrue(pom.contains("archetype-resources/.claude/skills/codename-one/SKILL.md"), + "the Claude stub must be staged at its single-file path"); + + String metadata = archetypeFile("src/main/resources/META-INF/maven/archetype-metadata.xml"); + for (String declared : new String[] { + ".agent-skills", + ".claude", + "AGENTS.md" }) { + assertTrue(metadata.contains(declared), + "archetype-metadata.xml must extract " + declared); + } + + // Java 8 projects get none of it: the skill's guidance is Java 17 throughout. + String postGenerate = archetypeFile("src/main/resources/META-INF/archetype-post-generate.groovy"); + for (String stripped : new String[] { "\".claude\"", "\".agent-skills\"", "\"AGENTS.md\"" }) { + assertTrue(postGenerate.contains(stripped), + "archetype-post-generate.groovy must strip " + stripped + " for Java 8"); + } + } + + private static String archetypeFile(String path) throws Exception { + File file = new File("../cn1app-archetype", path); + assertTrue(file.isFile(), "Missing archetype file " + file.getAbsolutePath()); + return new String(Files.readAllBytes(file.toPath()), StandardCharsets.UTF_8); + } + private static String pluginDescriptor() throws Exception { File pom = new File("pom.xml"); assertTrue(pom.isFile(), "expected the plugin pom at " + pom.getAbsolutePath()); diff --git a/scripts/initializr/common/src/main/java/com/codename1/initializr/model/GeneratorModel.java b/scripts/initializr/common/src/main/java/com/codename1/initializr/model/GeneratorModel.java index 219b2223b27..a0a75e9355d 100644 --- a/scripts/initializr/common/src/main/java/com/codename1/initializr/model/GeneratorModel.java +++ b/scripts/initializr/common/src/main/java/com/codename1/initializr/model/GeneratorModel.java @@ -69,52 +69,17 @@ public class GeneratorModel { private static final String AGENT_SKILL_TARGET_PREFIX = ".agent-skills/codename-one/"; private static final String CLAUDE_SKILL_STUB_PATH = ".claude/skills/codename-one/SKILL.md"; - private static final String CLAUDE_SKILL_STUB_BODY = - "---\n" - + "name: codename-one\n" - + "description: Build and modify Codename One cross-platform mobile apps (Java 17, Maven, ParparVM/Android/iOS/JavaScript). Use when the project contains a `common/codenameone_settings.properties`, depends on `com.codenameone:codenameone-core`, edits CSS files under `common/src/main/css/`, calls `cn1:run`, `cn1:test`, `cn1:build`, references `com.codename1.ui.*` / `com.codename1.testing.*`, or when the user asks to build a UI, write screen tests, generate screenshots, or compare to Swing/HTML/Android.\n" - + "metadata:\n" - + " type: skill\n" - + "---\n" - + "\n" - + "# Codename One — App and UI Authoring Skill (Claude Code stub)\n" - + "\n" - + "This file exists so Claude Code can index the Codename One authoring skill.\n" - + "The actual skill content is **vendor-neutral** and lives in this repository at:\n" - + "\n" - + "- `.agent-skills/codename-one/SKILL.md` — top-level cheat sheet\n" - + "- `.agent-skills/codename-one/references/*.md` — deep-dive references\n" - + "- `.agent-skills/codename-one/tools/` — runnable Java 17 utilities (`isApiSupported`, `isCssValid`, ...)\n" - + "\n" - + "**Read `.agent-skills/codename-one/SKILL.md` next.** All the guidance you need to\n" - + "build, style, test, debug, and port to Codename One is in that directory.\n"; - private static final String AGENTS_MD_BODY = - "# AGENTS.md\n" - + "\n" - + "This project is a Codename One cross-platform mobile app (Java 17 / Maven /\n" - + "ParparVM-iOS / Android / JavaScript / desktop). A vendor-neutral authoring skill\n" - + "is bundled in this repository for any AI agent:\n" - + "\n" - + "- **Start here:** `.agent-skills/codename-one/SKILL.md`\n" - + "- **Topical references:** `.agent-skills/codename-one/references/`\n" - + "- **Runnable utilities (Java 17 single-file source mode):** `.agent-skills/codename-one/tools/`\n" - + "\n" - + "Tool integrations (Claude Code, Cursor, etc.) may also pick this skill up via\n" - + "their own conventions; the canonical source of truth is `.agent-skills/`.\n" - + "\n" - + "## Quick orientation for an agent\n" - + "\n" - + "- App source lives in `common/src/main/java/`.\n" - + "- Theme/styling lives in `common/src/main/css/theme.css` (Codename One CSS — a\n" - + " deliberate subset, see `.agent-skills/codename-one/references/css.md`).\n" - + "- Run the simulator with `mvn -pl common cn1:run`.\n" - + "- Run tests with `mvn -pl common cn1:test` (on Linux CI use `xvfb-run -a`).\n" - + "- You can drive the RUNNING simulator yourself over MCP (read the screen, type,\n" - + " tap) - see `.agent-skills/codename-one/references/mcp-agent-control.md`.\n" - + "- Native cloud builds use `mvn -pl package -Dcodename1.platform=... -Dcodename1.buildTarget=...`.\n" - + "\n" - + "When in doubt, open `.agent-skills/codename-one/SKILL.md` and follow the\n" - + "reference table at the bottom.\n"; + // The AGENTS.md pointer and the Claude Code stub are FILES rather than string + // constants because maven/cn1app-archetype stages the very same two files into + // archetype-resources/ (see its pom). A project generated from the archetype and one + // downloaded from the Initializr have to hand an agent the same layout, and the only + // way to guarantee that is one copy on disk. + // + // They sit flat at the root of src/main/resources for the same reason skill/ is + // repackaged into skill.zip: Codename One's classloader rejects nested directories + // under src/main/resources at runtime. + private static final String CLAUDE_SKILL_STUB_RESOURCE = "/agent-skill-claude-stub.md"; + private static final String AGENTS_MD_RESOURCE = "/AGENTS.md"; private final IDE ide; private final Template template; @@ -433,10 +398,11 @@ private void addAgentSkillEntries(Map mergedEntries) throws IOEx } // Top-level AGENTS.md so agents that follow the (emerging) AGENTS.md convention // discover the skill without having to know our directory layout. - copySingleTextEntryToMap("AGENTS.md", AGENTS_MD_BODY, mergedEntries, ZipEntryType.COMMON); + copySingleTextEntryToMap("AGENTS.md", readResourceToString(AGENTS_MD_RESOURCE), + mergedEntries, ZipEntryType.COMMON); // Claude Code stub. Frontmatter so the skill shows up in /skills, body redirects // to the canonical vendor-neutral content. - copySingleTextEntryToMap(CLAUDE_SKILL_STUB_PATH, CLAUDE_SKILL_STUB_BODY, + copySingleTextEntryToMap(CLAUDE_SKILL_STUB_PATH, readResourceToString(CLAUDE_SKILL_STUB_RESOURCE), mergedEntries, ZipEntryType.COMMON); } diff --git a/scripts/initializr/common/src/main/resources/AGENTS.md b/scripts/initializr/common/src/main/resources/AGENTS.md new file mode 100644 index 00000000000..b57a6eb581c --- /dev/null +++ b/scripts/initializr/common/src/main/resources/AGENTS.md @@ -0,0 +1,29 @@ +# AGENTS.md + +This project is a Codename One cross-platform mobile app (Java 17 / Maven / +ParparVM-iOS / Android / JavaScript / desktop). A vendor-neutral authoring skill +is bundled in this repository for any AI agent: + +- **Start here:** `.agent-skills/codename-one/SKILL.md` +- **Topical references:** `.agent-skills/codename-one/references/` +- **Runnable utilities (Java 17 single-file source mode):** `.agent-skills/codename-one/tools/` + +Tool integrations (Claude Code, Cursor, etc.) may also pick this skill up via +their own conventions; the canonical source of truth is `.agent-skills/`. + +## Quick orientation for an agent + +- App source lives in `common/src/main/java/`. +- Theme/styling lives in `common/src/main/css/theme.css` (Codename One CSS — a + deliberate subset, see `.agent-skills/codename-one/references/css.md`). +- Run the simulator with `mvn -pl common cn1:run`. +- Run tests with `mvn -pl common cn1:test` (on Linux CI use `xvfb-run -a`). +- You can drive the RUNNING simulator yourself over MCP (read the screen, type, + tap) - see `.agent-skills/codename-one/references/mcp-agent-control.md`. +- A bug that only reproduces on an Android phone or an iPhone is still debuggable: + attach a Java debugger to the device build and drive it over MCP the same way; + see `.agent-skills/codename-one/references/on-device-debugging.md`. +- Native cloud builds use `mvn -pl package -Dcodename1.platform=... -Dcodename1.buildTarget=...`. + +When in doubt, open `.agent-skills/codename-one/SKILL.md` and follow the +reference table at the bottom. diff --git a/scripts/initializr/common/src/main/resources/agent-skill-claude-stub.md b/scripts/initializr/common/src/main/resources/agent-skill-claude-stub.md new file mode 100644 index 00000000000..294692ef1a0 --- /dev/null +++ b/scripts/initializr/common/src/main/resources/agent-skill-claude-stub.md @@ -0,0 +1,18 @@ +--- +name: codename-one +description: Build and modify Codename One cross-platform mobile apps (Java 17, Maven, ParparVM/Android/iOS/JavaScript). Use when the project contains a `common/codenameone_settings.properties`, depends on `com.codenameone:codenameone-core`, edits CSS files under `common/src/main/css/`, calls `cn1:run`, `cn1:test`, `cn1:build`, references `com.codename1.ui.*` / `com.codename1.testing.*`, or when the user asks to build a UI, write screen tests, generate screenshots, or compare to Swing/HTML/Android. +metadata: + type: skill +--- + +# Codename One — App and UI Authoring Skill (Claude Code stub) + +This file exists so Claude Code can index the Codename One authoring skill. +The actual skill content is **vendor-neutral** and lives in this repository at: + +- `.agent-skills/codename-one/SKILL.md` — top-level cheat sheet +- `.agent-skills/codename-one/references/*.md` — deep-dive references +- `.agent-skills/codename-one/tools/` — runnable Java 17 utilities (`isApiSupported`, `isCssValid`, ...) + +**Read `.agent-skills/codename-one/SKILL.md` next.** All the guidance you need to +build, style, test, debug, and port to Codename One is in that directory. diff --git a/scripts/initializr/common/src/main/resources/skill/SKILL.md b/scripts/initializr/common/src/main/resources/skill/SKILL.md index 815af7dca98..250541d892c 100644 --- a/scripts/initializr/common/src/main/resources/skill/SKILL.md +++ b/scripts/initializr/common/src/main/resources/skill/SKILL.md @@ -318,6 +318,7 @@ If you cannot run the simulator (e.g. headless environment), **say so explicitly | "Store an LLM API key" / non-prompting SecureStorage | `references/ai-and-speech.md` | | "Build against a Codename One SNAPSHOT from git" | `references/snapshot-builds.md` | | "Debug a faulty screen — attach `jdb` to the simulator" | `references/debugging.md` | +| "It only breaks on the phone" / "attach a debugger to the Android/iOS build" / `android.onDeviceDebug`, `ios.onDeviceDebug` / drive the app on a device over MCP | `references/on-device-debugging.md` | | "Try the flow" / "fill in this form and press submit" / "drive the running app" / MCP | `references/mcp-agent-control.md` | | Quick yes/no check: "is this `java.*` class supported", "does my `theme.css` compile" | `tools/` directory — `java tools/IsApiSupported.java ` / `java tools/IsCssValid.java ` | | "Score this screen against a mockup" / "Import a Figma/Sketch/XD design" | `tools/` directory — `java tools/CompareToMockup.java ` / `java tools/DesignImport.java ` (see `references/mockup-comparison.md`) | diff --git a/scripts/initializr/common/src/main/resources/skill/references/debugging.md b/scripts/initializr/common/src/main/resources/skill/references/debugging.md index 6dd6143ac8b..b65e2bf7616 100644 --- a/scripts/initializr/common/src/main/resources/skill/references/debugging.md +++ b/scripts/initializr/common/src/main/resources/skill/references/debugging.md @@ -157,4 +157,4 @@ For UI-rendering bugs (a label shows the wrong text, a colour is off, a layout h For build / compile errors there's no JVM running yet. Use `mvn -pl common compile -e -X` for verbose Maven diagnostics; consult `references/build-and-run.md` for the common build error patterns. -For native-side bugs on iOS / Android the simulator's JVM doesn't apply at all. Use Xcode's debugger (for `ios-source` builds) or Android Studio attached to the running APK. +For a bug that only reproduces on real hardware, the simulator's JVM doesn't apply at all. You can still attach a **Java** debugger to the build running on the device, and drive it over MCP while you are there — see `references/on-device-debugging.md`. For the native layer below that (Objective-C, NDK C/C++), use Xcode's debugger on an `ios-source` build or Android Studio attached to the running APK; those attaches are independent of the JDWP one and can run beside it. diff --git a/scripts/initializr/common/src/main/resources/skill/references/mcp-agent-control.md b/scripts/initializr/common/src/main/resources/skill/references/mcp-agent-control.md index 4ddef4b5582..4e641d4de0a 100644 --- a/scripts/initializr/common/src/main/resources/skill/references/mcp-agent-control.md +++ b/scripts/initializr/common/src/main/resources/skill/references/mcp-agent-control.md @@ -28,6 +28,12 @@ If you are running the app yourself rather than through the menu, the same switc MCP.startSocketServer(8765); ``` +## Attaching to a build on a device + +The socket server is not simulator-only. It binds wherever the platform can bind a loopback port, which includes an app running on an Android phone or an iPhone, so the same five tools drive the real build — semantic identifiers instead of coordinates guessed off a screenshot. The port is on the *device's* loopback rather than yours, so it needs a forward: `adb forward tcp:8765 tcp:8765` on Android, nothing at all on the native iOS simulator (it shares your machine's network stack), and a usbmux relay on a physical iPhone. + +`references/on-device-debugging.md` has the whole sequence for both platforms, alongside the JDWP attach that usually goes with it. + ## What you can call Every server publishes these, with no work from the app: diff --git a/scripts/initializr/common/src/main/resources/skill/references/on-device-debugging.md b/scripts/initializr/common/src/main/resources/skill/references/on-device-debugging.md new file mode 100644 index 00000000000..b7214f7cb8e --- /dev/null +++ b/scripts/initializr/common/src/main/resources/skill/references/on-device-debugging.md @@ -0,0 +1,132 @@ +# Debugging on a Real Device — Android and iOS + +`references/debugging.md` attaches `jdb` to the **simulator**, which is a plain JVM on your own machine. This file is the other case: the bug only shows up in a build running on an Android phone, an iPhone, or one of the native emulators, and you need a Java debugger — and optionally an MCP session — against *that* process. + +**Reach for the simulator first.** It is faster, it has no device-side moving parts, and every debugger feature works there. Come here only when the behaviour genuinely differs on a device: ParparVM threading, a native interface, iOS layout under the modern theme, memory pressure, or timing around UIKit and the Android lifecycle. + +Both platforms need a build made with an on-device-debug build hint, because the flag is baked into the binary. A release build cannot be attached to. + +## Android + +Android's runtime already exposes a JDWP socket per debuggable process, so there is no proxy — the Maven goal just drives `adb`. + +**Prerequisites.** Android platform-tools installed, with `adb` reachable through `ANDROID_HOME`, `ANDROID_SDK_ROOT`, or `PATH`. The device needs USB debugging enabled and the per-machine RSA prompt accepted. + +**1. Turn the hint on** in `common/codenameone_settings.properties`: + +```properties +codename1.arg.android.onDeviceDebug=true +``` + +That flips the generated `AndroidManifest.xml` to `debuggable="true"` and disables R8/proguard so symbols and locals survive. + +**2. Build a debuggable APK**, either through the cloud builder or entirely locally: + +```bash +# Cloud build. Forces the hint for this one build, so you can skip step 1. +mvn cn1:buildAndroidOnDeviceDebug + +# Or fully local: generate the Gradle project and assemble it yourself. +mvn cn1:buildAndroidGradleProject +cd android/target/*-android-source && ./gradlew assembleDebug && cd - +``` + +**3. Install, launch, forward JDWP, and tail logcat** — one goal does all four: + +```bash +mvn cn1:android-on-device-debugging +``` + +It prints the adb it picked, the target device, the app's PID, and a banner confirming `localhost:5005` is forwarded. Logcat, filtered to that PID, then streams through the same terminal prefixed with `[device]`. + +**4. Attach a debugger** from a second terminal (or point your IDE's remote-JVM config at the same port): + +```bash +jdb -attach localhost:5005 \ + -sourcepath common/src/main/java +``` + +By default the goal runs `am set-debug-app -w`, so the app sits paused at startup until you attach. Everything the simulator debugger does works here — this is the real Android runtime, not a proxy. + +**Flags worth knowing:** + +| Flag | Use it when | +| --- | --- | +| `-Dcn1.android.onDeviceDebug.deviceSerial=` | More than one device is online (`adb devices` lists serials). | +| `-Dcn1.android.onDeviceDebug.wireless=` | Run `adb connect ` first — covers both the Android 11+ *Wireless debugging* pairing flow and the older `adb tcpip` one. | +| `-Dcn1.android.onDeviceDebug.apk=` | Skip APK autodetection and install this file. | +| `-Dcn1.android.onDeviceDebug.jdwpPort=` | Something else already holds 5005. | +| `-Dcn1.android.onDeviceDebug.skipInstall=true` | The build is already on the device. | +| `-Dcn1.android.onDeviceDebug.waitForAttach=false` | You want the app to boot immediately instead of blocking for a debugger — the right choice when what you actually want is the MCP session below, not a breakpoint. | + +## iOS + +iOS has no JDWP socket of its own, so Codename One adds a listener thread to the ParparVM-generated binary and a desktop proxy speaks JDWP on the IDE's behalf. **The app dials out to the proxy**, which is why the build has to know where your machine is. + +**1. Turn the hints on** in `common/codenameone_settings.properties`: + +```properties +codename1.arg.ios.onDeviceDebug=true +codename1.arg.ios.onDeviceDebug.proxyHost=127.0.0.1 +codename1.arg.ios.onDeviceDebug.proxyPort=55333 +# Optional: hold the app at startup until the debugger attaches. +codename1.arg.ios.onDeviceDebug.waitForAttach=true +``` + +`proxyHost` stays `127.0.0.1` for the **native iOS simulator**, which shares your machine's loopback. For a **physical iPhone** it must be your machine's LAN address (`ifconfig` / `ipconfig`), reachable from the phone's Wi-Fi network — the phone opens the connection, so a host that only answers on loopback will never be reached. + +**2. Build:** + +```bash +# Cloud build for a physical device. Forces the hint for this one build. +mvn cn1:buildIosOnDeviceDebug + +# Or generate a local Xcode project (macOS with Xcode only) and run it from there. +mvn cn1:buildIosXcodeProject +``` + +**3. Start the proxy** and leave it running: + +```bash +mvn cn1:ios-on-device-debugging +``` + +**4. Launch the app**, then wait for the proxy to print that the device connected and the symbols loaded. The binary carries its own compressed symbol table and streams it over on connect, so there is no sidecar file to find. + +**5. Attach** once the handshake lines appear: + +```bash +jdb -attach localhost:8000 \ + -sourcepath common/src/main/java +``` + +**Two ports, and mixing them up is the usual mistake:** the *app* dials the proxy on **55333**; the *debugger* attaches to the proxy on **8000**. Override them with `-Dcn1.onDeviceDebug.devicePort` and `-Dcn1.onDeviceDebug.jdwpPort` if either is taken. + +**iOS limits you will hit.** Breakpoints, stepping, stack walking, locals, instance fields, arrays, threads, and method invocation on framework and user classes all work. These do not: constructor invocation (`new Foo(...)`), hot-swap, and static field reads. Method invocation is also skipped for `java.io.*`, `java.net.*`, `java.nio.*`, and `com.codename1.impl.*`. If an expression silently refuses to evaluate, that list is usually why — report it rather than concluding the value is wrong. + +## Driving the app on the device over MCP + +The MCP server from `references/mcp-agent-control.md` is not simulator-only: it binds a loopback port anywhere the platform can bind one, which includes a build running on a device. That gives you `ui_snapshot` / `ui_find` / `ui_activate` / `ui_set_text` against the real thing — stable semantic identifiers instead of guessed screen coordinates. + +Start it from your own code, gated so it cannot reach a shipped build: + +```java +// In MyAppName.start() +if (Display.getInstance().isDebuggableBuild()) { + MCP.startSocketServer(8765); +} +``` + +The port is on the **device's** loopback, not yours, so it needs a forward: + +- **Android device or emulator** — `adb forward tcp:8765 tcp:8765`, then connect to `127.0.0.1:8765` on your machine. Tear it down with `adb forward --remove tcp:8765` when you are done. +- **Native iOS simulator** — it shares your machine's network stack, so `127.0.0.1:8765` already *is* the app's port. Nothing to forward. +- **Physical iPhone** — the port is on the phone's own loopback and there is no Codename One goal that tunnels it. The route is a usbmux TCP relay such as `iproxy 8765 8765` from libimobiledevice. If you do not have that tooling, use the iOS simulator for the MCP loop and keep the physical device for the JDWP session above. + +Pair this with `waitForAttach=false` on Android when you want the app to boot straight into a drivable state rather than blocking for a debugger. + +**Security, unchanged from the simulator case:** loopback is not authentication. Everything on the device can reach the port, which is exactly why `startSocketServer` refuses on a release build. Do not lift that gate to make a device session work, do not leave the starter in shipping code, and remove the forward when the session ends. + +## Say what you could not check + +Both flows need hardware, an SDK, and — on iOS — a machine on the same network as the phone. If you do not have the device, the platform tools, or a build with the hint, say so plainly instead of reporting that the behaviour was verified. diff --git a/scripts/initializr/common/src/test/java/com/codename1/initializr/model/GeneratorModelMatrixTest.java b/scripts/initializr/common/src/test/java/com/codename1/initializr/model/GeneratorModelMatrixTest.java index 8018bb57811..641d0c0d2bc 100644 --- a/scripts/initializr/common/src/test/java/com/codename1/initializr/model/GeneratorModelMatrixTest.java +++ b/scripts/initializr/common/src/test/java/com/codename1/initializr/model/GeneratorModelMatrixTest.java @@ -170,6 +170,7 @@ private void validateClaudeSkillBundled() throws Exception { ".agent-skills/codename-one/references/snapshot-builds.md", ".agent-skills/codename-one/references/debugging.md", ".agent-skills/codename-one/references/mcp-agent-control.md", + ".agent-skills/codename-one/references/on-device-debugging.md", ".agent-skills/codename-one/references/ai-and-speech.md", ".agent-skills/codename-one/tools/README.md", ".agent-skills/codename-one/tools/IsApiSupported.java", @@ -200,6 +201,10 @@ private void validateClaudeSkillBundled() throws Exception { // screenshots, so the pointer to the MCP loop belongs in the root file too. assertContains(agentsMd, "references/mcp-agent-control.md", "AGENTS.md should point agents at the MCP control loop"); + // Same reasoning for the device loops: an agent that never learns the Android/iOS + // build is attachable will give up at "cannot reproduce in the simulator". + assertContains(agentsMd, "references/on-device-debugging.md", + "AGENTS.md should point agents at the on-device debug/MCP loops"); String claudeStub = getText(entries, ".claude/skills/codename-one/SKILL.md"); assertContains(claudeStub, "name: codename-one", "Claude stub must keep the skill frontmatter"); From b739df0659b0e85ea4589092c175e7ad2e691d55 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Fri, 18 Sep 2026 22:12:42 +0300 Subject: [PATCH 2/8] Document how an agent reaches the MCP port on a device The MCP chapter already says the socket transport works on a phone, then stops: it never says the port is the phone's loopback rather than yours, so the one thing a reader has to do -- forward it -- is left out. Issue #5699 asks for the attachment route per platform to be explicit. Android is adb forward, and worth pairing with android-on-device-debugging's waitForAttach=false, since an agent session wants the app booted rather than blocked on a debugger. The native iOS simulator needs nothing, because it shares the host's network stack. A physical iPhone needs a usbmux relay we do not ship, which is said plainly rather than left for a reader to discover. Co-Authored-By: Claude Opus 5 (1M context) --- docs/developer-guide/MCP-Headless-API.asciidoc | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/docs/developer-guide/MCP-Headless-API.asciidoc b/docs/developer-guide/MCP-Headless-API.asciidoc index 451253fed04..ebb0a355311 100644 --- a/docs/developer-guide/MCP-Headless-API.asciidoc +++ b/docs/developer-guide/MCP-Headless-API.asciidoc @@ -34,6 +34,16 @@ include::../demos/common/src/main/java/com/codenameone/developerguide/snippets/M The stdio transport is the standard MCP local transport, exchanging newline delimited JSON-RPC messages. While it runs, application logging is redirected away from standard output so it can't corrupt the protocol stream. The stdio transport lives in the JavaSE port because it needs process standard input, which isn't available on every target. +=== Attaching to an application on a device + +The socket transport binds the loopback interface, and it binds the loopback interface of whatever machine the application is running on. In the simulator that's your own, so an agent connects to `127.0.0.1` and there's nothing else to arrange. On a phone the port belongs to the phone, and reaching it needs a forward. + +On Android, `adb forward tcp:8765 tcp:8765` maps a port on the development machine onto the same port inside the device, so an agent connects to `127.0.0.1:8765` as before. Remove it with `adb forward --remove tcp:8765` when the session ends. The `cn1:android-on-device-debugging` goal already installs and launches a debuggable build, and its `waitForAttach=false` option is the one to use when the point of the session is the agent rather than a breakpoint, since the application then boots straight into a drivable state instead of blocking for a debugger. + +The native iOS simulator shares the host's network stack, so the application's loopback port is the host's loopback port and no forwarding applies. A physical iPhone does not: the port sits on the device's own loopback, reachable only through a usbmux TCP relay such as `iproxy` from libimobiledevice. Codename One ships no goal for that, so an agent-driven session against a real iPhone depends on that external tooling in a way the Android one doesn't. + +None of this is a second security boundary. A forward is something you asked for, and the reason the server refuses a release build is that the loopback interface on a device is shared with every other application installed on it -- see <>. + [[development-builds-only]] === Development builds only From 590f48bb4a1e340fa90018fd183c68bad722d42f Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sat, 19 Sep 2026 01:12:47 +0300 Subject: [PATCH 3/8] Use the contraction the guide's Vale config requires Microsoft.Contractions is enabled for the developer guide, and "does not" in the new device-attachment section was the one error in the Vale report that failed "Build Developer Guide Docs". Vale is clean across all 123 guide files with this. Co-Authored-By: Claude Opus 5 (1M context) --- docs/developer-guide/MCP-Headless-API.asciidoc | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/developer-guide/MCP-Headless-API.asciidoc b/docs/developer-guide/MCP-Headless-API.asciidoc index ebb0a355311..568bac8526e 100644 --- a/docs/developer-guide/MCP-Headless-API.asciidoc +++ b/docs/developer-guide/MCP-Headless-API.asciidoc @@ -40,7 +40,7 @@ The socket transport binds the loopback interface, and it binds the loopback int On Android, `adb forward tcp:8765 tcp:8765` maps a port on the development machine onto the same port inside the device, so an agent connects to `127.0.0.1:8765` as before. Remove it with `adb forward --remove tcp:8765` when the session ends. The `cn1:android-on-device-debugging` goal already installs and launches a debuggable build, and its `waitForAttach=false` option is the one to use when the point of the session is the agent rather than a breakpoint, since the application then boots straight into a drivable state instead of blocking for a debugger. -The native iOS simulator shares the host's network stack, so the application's loopback port is the host's loopback port and no forwarding applies. A physical iPhone does not: the port sits on the device's own loopback, reachable only through a usbmux TCP relay such as `iproxy` from libimobiledevice. Codename One ships no goal for that, so an agent-driven session against a real iPhone depends on that external tooling in a way the Android one doesn't. +The native iOS simulator shares the host's network stack, so the application's loopback port is the host's loopback port and no forwarding applies. A physical iPhone doesn't: the port sits on the device's own loopback, reachable only through a usbmux TCP relay such as `iproxy` from libimobiledevice. Codename One ships no goal for that, so an agent-driven session against a real iPhone depends on that external tooling in a way the Android one doesn't. None of this is a second security boundary. A forward is something you asked for, and the reason the server refuses a release build is that the loopback interface on a device is shared with every other application installed on it -- see <>. From fb5f2d905f9dad1d913f931e64425e71c9e2ac05 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sat, 19 Sep 2026 03:46:45 +0300 Subject: [PATCH 4/8] Clear the LanguageTool findings the device-attachment paragraph introduced The developer-guide gate fails on ANY LanguageTool match, not just on a non-zero exit, so the three matches in the new paragraph were build-breaking even with Vale clean. Two of them are one sentence: "a usbmux TCP relay" trips EN_A_VS_AN (the rule guesses a vowel sound for a word it has never seen) and MORFOLOGIK on "usbmux" itself. Neither is a real defect, but the accept list matches the whole flagged span, and the span for the article rule is the bare "a" -- which cannot be accepted without accepting every "a" in the guide. So the sentence names the mechanism in words the dictionary has ("the USB multiplexing channel") and keeps iproxy, which is already masked as inline code. libimobiledevice is a genuine proper noun the dictionary lacks, which is exactly what the accept list is for. Verified by rendering the chapter with asciidoctor and running scripts/developer-guide/run_languagetool.py over it: 0 matches with this, and the pre-commit text still reports the article and spelling matches, so the check is answering about this prose rather than passing vacuously. Vale is 0 across all 123 guide files. Co-Authored-By: Claude Opus 5 (1M context) --- docs/developer-guide/MCP-Headless-API.asciidoc | 2 +- docs/developer-guide/languagetool-accept.txt | 7 +++++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/docs/developer-guide/MCP-Headless-API.asciidoc b/docs/developer-guide/MCP-Headless-API.asciidoc index 568bac8526e..f3e9612e41a 100644 --- a/docs/developer-guide/MCP-Headless-API.asciidoc +++ b/docs/developer-guide/MCP-Headless-API.asciidoc @@ -40,7 +40,7 @@ The socket transport binds the loopback interface, and it binds the loopback int On Android, `adb forward tcp:8765 tcp:8765` maps a port on the development machine onto the same port inside the device, so an agent connects to `127.0.0.1:8765` as before. Remove it with `adb forward --remove tcp:8765` when the session ends. The `cn1:android-on-device-debugging` goal already installs and launches a debuggable build, and its `waitForAttach=false` option is the one to use when the point of the session is the agent rather than a breakpoint, since the application then boots straight into a drivable state instead of blocking for a debugger. -The native iOS simulator shares the host's network stack, so the application's loopback port is the host's loopback port and no forwarding applies. A physical iPhone doesn't: the port sits on the device's own loopback, reachable only through a usbmux TCP relay such as `iproxy` from libimobiledevice. Codename One ships no goal for that, so an agent-driven session against a real iPhone depends on that external tooling in a way the Android one doesn't. +The native iOS simulator shares the host's network stack, so the application's loopback port is the host's loopback port and no forwarding applies. A physical iPhone doesn't: the port sits on the device's own loopback, reachable only over the USB multiplexing channel, and the usual relay for that is `iproxy` from libimobiledevice. Codename One ships no goal for it, so an agent-driven session against a real iPhone depends on external tooling in a way the Android one doesn't. None of this is a second security boundary. A forward is something you asked for, and the reason the server refuses a release build is that the loopback interface on a device is shared with every other application installed on it -- see <>. diff --git a/docs/developer-guide/languagetool-accept.txt b/docs/developer-guide/languagetool-accept.txt index e29a5fcf918..719b8004276 100644 --- a/docs/developer-guide/languagetool-accept.txt +++ b/docs/developer-guide/languagetool-accept.txt @@ -759,3 +759,10 @@ resizer epoll # A benchmark of one narrow operation, as against a whole application. microbenchmark + +# ----------------------------------------------------------------------------- +# Attaching an agent to a build on a device (MCP-Headless-API.asciidoc). +# ----------------------------------------------------------------------------- +# The open-source library whose iproxy tool relays a TCP port to an iPhone over +# USB. A project name, spelled lowercase by its authors. +libimobiledevice From cf4885d399c4097477fd521cfd9749fbaf70d29d Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sat, 19 Sep 2026 05:55:14 +0300 Subject: [PATCH 5/8] Address the review: reserved AGENTS.md name, the iOS waitForAttach trap, and multi-device adb Three findings, all real, plus the Vale error the last push left behind. The AGENTS.md body was stored under that literal name inside this repository, so an agent working on scripts/initializr/common/src/main/resources/** would read it as instructions for the Codename One tree -- and it says things like "app source lives in common/src/main/java" and "run the simulator with mvn -pl common cn1:run", which describe a GENERATED application and are wrong here. It moves to agent-skill-agents-md.md and is renamed on staging, the way the Claude stub already was. The two renames have opposite causes and the comments now say which is which. The archetype parity test asserts both halves: the source name exists, and the reserved one does not. waitForAttach on iOS is not the overlay it looks like. CodenameOne_GLAppDelegate wraps the VM callback in cn1_debugger_run_when_ready, which stashes the block until the proxy reports an IDE attached, so start() never runs and a MCP.startSocketServer call inside it never fires. The reference printed waitForAttach=true in the hints block and mentioned the false setting only for Android, which would have left an agent waiting on a port nothing was listening to. Said in the iOS section, in the MCP section, and in the developer guide. adb refuses forward and forward --remove outright when several devices are online rather than picking one, which is the case the reference already hands deviceSerial for. Both commands now show the -s form. Vale: "the usual relay for that is" tripped Microsoft.Contractions, which the previous push introduced while fixing the LanguageTool findings and did not recheck -- Vale had been re-run before that reword, not after. Both gates are now confirmed on the final text: Vale 0 across 123 files, LanguageTool 0 on the rendered chapter. Re-verified end to end after the rename: the real Initializr ZIPs still carry AGENTS.md, and a project generated from a clean-built archetype has AGENTS.md, 37 files under .agent-skills/codename-one, the .claude stub, and no file left under the source name. Co-Authored-By: Claude Opus 5 (1M context) --- .../developer-guide/MCP-Headless-API.asciidoc | 4 +-- maven/cn1app-archetype/pom.xml | 28 +++++++++---------- .../Cn1AppArchetypeCertificateWizardTest.java | 11 ++++++-- .../initializr/model/GeneratorModel.java | 9 +++++- .../{AGENTS.md => agent-skill-agents-md.md} | 0 .../skill/references/on-device-debugging.md | 6 ++-- 6 files changed, 36 insertions(+), 22 deletions(-) rename scripts/initializr/common/src/main/resources/{AGENTS.md => agent-skill-agents-md.md} (100%) diff --git a/docs/developer-guide/MCP-Headless-API.asciidoc b/docs/developer-guide/MCP-Headless-API.asciidoc index f3e9612e41a..c2f59920196 100644 --- a/docs/developer-guide/MCP-Headless-API.asciidoc +++ b/docs/developer-guide/MCP-Headless-API.asciidoc @@ -38,9 +38,9 @@ The stdio transport is the standard MCP local transport, exchanging newline deli The socket transport binds the loopback interface, and it binds the loopback interface of whatever machine the application is running on. In the simulator that's your own, so an agent connects to `127.0.0.1` and there's nothing else to arrange. On a phone the port belongs to the phone, and reaching it needs a forward. -On Android, `adb forward tcp:8765 tcp:8765` maps a port on the development machine onto the same port inside the device, so an agent connects to `127.0.0.1:8765` as before. Remove it with `adb forward --remove tcp:8765` when the session ends. The `cn1:android-on-device-debugging` goal already installs and launches a debuggable build, and its `waitForAttach=false` option is the one to use when the point of the session is the agent rather than a breakpoint, since the application then boots straight into a drivable state instead of blocking for a debugger. +On Android, `adb forward tcp:8765 tcp:8765` maps a port on the development machine onto the same port inside the device, so an agent connects to `127.0.0.1:8765` as before. Remove it with `adb forward --remove tcp:8765` when the session ends. The `cn1:android-on-device-debugging` goal already installs and launches a debuggable build, and its `waitForAttach=false` option is the one to use when the point of the session is the agent rather than a breakpoint, since the application then boots straight into a drivable state instead of blocking for a debugger. On iOS the same setting isn't a convenience but a requirement, because `ios.onDeviceDebug.waitForAttach` defers the callback that boots the application until a debugger attaches -- so a starter in the application's own code never runs, and there is nothing listening on the port to reach. -The native iOS simulator shares the host's network stack, so the application's loopback port is the host's loopback port and no forwarding applies. A physical iPhone doesn't: the port sits on the device's own loopback, reachable only over the USB multiplexing channel, and the usual relay for that is `iproxy` from libimobiledevice. Codename One ships no goal for it, so an agent-driven session against a real iPhone depends on external tooling in a way the Android one doesn't. +The native iOS simulator shares the host's network stack, so the application's loopback port is the host's loopback port and no forwarding applies. A physical iPhone doesn't: the port sits on the device's own loopback, reachable only over the USB multiplexing channel, and `iproxy` from libimobiledevice is the usual relay. Codename One ships no goal for it, so an agent-driven session against a real iPhone depends on external tooling in a way the Android one doesn't. None of this is a second security boundary. A forward is something you asked for, and the reason the server refuses a release build is that the loopback interface on a device is shared with every other application installed on it -- see <>. diff --git a/maven/cn1app-archetype/pom.xml b/maven/cn1app-archetype/pom.xml index d29ca680314..c7ee0188195 100644 --- a/maven/cn1app-archetype/pom.xml +++ b/maven/cn1app-archetype/pom.xml @@ -68,14 +68,6 @@ archetype-resources/.agent-skills/codename-one false - - ${project.basedir}/../../scripts/initializr/common/src/main/resources - archetype-resources - false - - AGENTS.md - - @@ -101,24 +93,32 @@ org.apache.maven.plugins maven-antrun-plugin - stage-claude-skill-stub + stage-agent-skill-pointers process-resources run + diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/maven/Cn1AppArchetypeCertificateWizardTest.java b/maven/codenameone-maven-plugin/src/test/java/com/codename1/maven/Cn1AppArchetypeCertificateWizardTest.java index 7eb1c605b8c..19a20130f5a 100644 --- a/maven/codenameone-maven-plugin/src/test/java/com/codename1/maven/Cn1AppArchetypeCertificateWizardTest.java +++ b/maven/codenameone-maven-plugin/src/test/java/com/codename1/maven/Cn1AppArchetypeCertificateWizardTest.java @@ -139,8 +139,13 @@ void generatedRunConfigurationsUseTheRegisteredGoalPrefix() throws Exception { void agentSkillIsGeneratedInTheSameLayoutAsTheInitializr() throws Exception { File initializrResources = new File("../../scripts/initializr/common/src/main/resources"); - assertTrue(new File(initializrResources, "AGENTS.md").isFile(), - "the archetype pom stages AGENTS.md from " + initializrResources); + // Stored under a name that is NOT AGENTS.md on purpose: that name is one agents + // look for by themselves, and a file called that inside this repository would be + // read as instructions for the Codename One tree rather than for a generated app. + assertTrue(new File(initializrResources, "agent-skill-agents-md.md").isFile(), + "the archetype pom stages the AGENTS.md body from " + initializrResources); + assertFalse(new File(initializrResources, "AGENTS.md").isFile(), + "the AGENTS.md body must not be stored under the reserved name"); assertTrue(new File(initializrResources, "agent-skill-claude-stub.md").isFile(), "the archetype pom stages the Claude stub from " + initializrResources); assertTrue(new File(initializrResources, "skill/SKILL.md").isFile(), @@ -149,7 +154,7 @@ void agentSkillIsGeneratedInTheSameLayoutAsTheInitializr() throws Exception { String pom = archetypeFile("pom.xml"); assertTrue(pom.contains("archetype-resources/.agent-skills/codename-one"), "the skill must be staged under .agent-skills/, not only under .claude/"); - assertTrue(pom.contains("AGENTS.md"), + assertTrue(pom.contains("archetype-resources/AGENTS.md"), "the root AGENTS.md pointer must be staged into the archetype"); assertTrue(pom.contains("archetype-resources/.claude/skills/codename-one/SKILL.md"), "the Claude stub must be staged at its single-file path"); diff --git a/scripts/initializr/common/src/main/java/com/codename1/initializr/model/GeneratorModel.java b/scripts/initializr/common/src/main/java/com/codename1/initializr/model/GeneratorModel.java index a0a75e9355d..46d6666b785 100644 --- a/scripts/initializr/common/src/main/java/com/codename1/initializr/model/GeneratorModel.java +++ b/scripts/initializr/common/src/main/java/com/codename1/initializr/model/GeneratorModel.java @@ -78,8 +78,15 @@ public class GeneratorModel { // They sit flat at the root of src/main/resources for the same reason skill/ is // repackaged into skill.zip: Codename One's classloader rejects nested directories // under src/main/resources at runtime. + // + // Neither is stored under the name it is published as. AGENTS.md is a name agents + // look for on their own, so a file called that here would be read as instructions + // for THIS repository -- and it says things like "run the simulator with + // mvn -pl common cn1:run", which is true of a generated app and false of the + // Codename One tree. The Claude stub already had to be renamed on staging because + // its published name is SKILL.md; this one is renamed for the opposite reason. private static final String CLAUDE_SKILL_STUB_RESOURCE = "/agent-skill-claude-stub.md"; - private static final String AGENTS_MD_RESOURCE = "/AGENTS.md"; + private static final String AGENTS_MD_RESOURCE = "/agent-skill-agents-md.md"; private final IDE ide; private final Template template; diff --git a/scripts/initializr/common/src/main/resources/AGENTS.md b/scripts/initializr/common/src/main/resources/agent-skill-agents-md.md similarity index 100% rename from scripts/initializr/common/src/main/resources/AGENTS.md rename to scripts/initializr/common/src/main/resources/agent-skill-agents-md.md diff --git a/scripts/initializr/common/src/main/resources/skill/references/on-device-debugging.md b/scripts/initializr/common/src/main/resources/skill/references/on-device-debugging.md index b7214f7cb8e..9103c850bf8 100644 --- a/scripts/initializr/common/src/main/resources/skill/references/on-device-debugging.md +++ b/scripts/initializr/common/src/main/resources/skill/references/on-device-debugging.md @@ -73,6 +73,8 @@ codename1.arg.ios.onDeviceDebug.proxyPort=55333 codename1.arg.ios.onDeviceDebug.waitForAttach=true ``` +**`waitForAttach=true` blocks an MCP-only session.** It does not merely show a "waiting for debugger" overlay: the app delegate defers the VM callback that boots Codename One until the proxy reports an IDE attached, so `start()` never runs and a `MCP.startSocketServer` call inside it never fires. Set it to `false` whenever the point of the session is driving the app rather than a breakpoint. The Android goal has the same trap and the same answer (`waitForAttach=false`). + `proxyHost` stays `127.0.0.1` for the **native iOS simulator**, which shares your machine's loopback. For a **physical iPhone** it must be your machine's LAN address (`ifconfig` / `ipconfig`), reachable from the phone's Wi-Fi network — the phone opens the connection, so a host that only answers on loopback will never be reached. **2. Build:** @@ -119,11 +121,11 @@ if (Display.getInstance().isDebuggableBuild()) { The port is on the **device's** loopback, not yours, so it needs a forward: -- **Android device or emulator** — `adb forward tcp:8765 tcp:8765`, then connect to `127.0.0.1:8765` on your machine. Tear it down with `adb forward --remove tcp:8765` when you are done. +- **Android device or emulator** — `adb forward tcp:8765 tcp:8765`, then connect to `127.0.0.1:8765` on your machine. Tear it down with `adb forward --remove tcp:8765` when you are done. With more than one device online `adb` refuses both commands rather than guessing, so pass the same serial the debug goal used: `adb -s forward tcp:8765 tcp:8765` and `adb -s forward --remove tcp:8765`. - **Native iOS simulator** — it shares your machine's network stack, so `127.0.0.1:8765` already *is* the app's port. Nothing to forward. - **Physical iPhone** — the port is on the phone's own loopback and there is no Codename One goal that tunnels it. The route is a usbmux TCP relay such as `iproxy 8765 8765` from libimobiledevice. If you do not have that tooling, use the iOS simulator for the MCP loop and keep the physical device for the JDWP session above. -Pair this with `waitForAttach=false` on Android when you want the app to boot straight into a drivable state rather than blocking for a debugger. +**Set `waitForAttach=false` on both platforms.** It is a convenience on Android and a requirement on iOS, where the VM callback that boots the app is itself deferred until a debugger attaches, so with it left on, the starter above never runs and nothing is listening to forward to. **Security, unchanged from the simulator case:** loopback is not authentication. Everything on the device can reach the port, which is exactly why `startSocketServer` refuses on a release build. Do not lift that gate to make a device session work, do not leave the starter in shipping code, and remove the forward when the session ends. From d95f9d8732f9f567c7c0eefbce13b5f935a08b44 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sat, 19 Sep 2026 09:09:20 +0300 Subject: [PATCH 6/8] iOS on-device debug: declare local-network access when the proxy is not loopback An on-device-debug app dials OUT to the proxy on the developer's machine. For a physical iPhone that is an address on the Wi-Fi the two share, and since iOS 14 reaching it is consent-gated local-network access that needs a purpose string declared up front -- which iOS terminates an app for lacking, as the Matter block in this same builder already says. The build injected CN1ProxyHost, CN1ProxyPort, CN1ProxyWaitForAttach and an ATS exemption and stopped there, so an app built exactly as the guide describes could be killed the moment cn1_debugger dialled out, leaving the proxy waiting and nothing on the device to say why. NSLocalNetworkUsageDescription was auto-injected only for Bonjour. Injected only when the proxy host is not loopback. The native simulator shares the host's loopback, which is not the local network, and an unnecessary purpose string puts a prompt in front of a developer who never asked for one -- the reason the nearby flags are kept apart from one another. Ambiguity resolves towards declaring it: a host that is not recognisably loopback may still be a LAN name, and the costs are not symmetric -- a spare string costs one prompt in a build that is debug-only by construction, a missing one costs a session that cannot start. Through applyCatalogPlistEntry rather than putArgument, for the reason the Matter and CallKit blocks both record: the sweep that copies ios.NS*UsageDescription hints into privacyUsageDescriptions runs long before this point and the plist is rendered from that map, so a bare argument set here would never have been read. It fills only a missing value, so a project with its own wording keeps it. Verified against a real generated project rather than by reading. Built the sample through cn1:buildIosXcodeProject's path (-Dcodename1.buildTarget=ios-source) and read HelloCodenameOne-Info.plist three times: proxyHost=192.168.1.42 -> the key present exactly once, plutil -lint OK proxyHost=127.0.0.1 -> the key absent, no prompt added proxyHost=192.168.1.42 + own description -> "My own wording." survives The predicate has its own test. SpotBugs is at zero findings for the module and the build-hint catalog gate passes; the hint itself is already declared in IosPrivacy, so nothing new is introduced. Co-Authored-By: Claude Opus 5 (1M context) --- .../com/codename1/builders/IPhoneBuilder.java | 79 +++++++++++++ ...eBuilderOnDeviceDebugLocalNetworkTest.java | 107 ++++++++++++++++++ 2 files changed, 186 insertions(+) create mode 100644 maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/IPhoneBuilderOnDeviceDebugLocalNetworkTest.java 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 ea44ca07260..5682b5c9aed 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 @@ -1831,6 +1831,55 @@ private void applyCatalogPlistEntry(BuildRequest request, } } + /** + * Whether an on-device-debug proxy host is the loopback interface, and so needs + * no local-network declaration. + * + * Deliberately a SMALL allow-list rather than a parse: everything it does not + * recognise is treated as the local network, which is the answer that keeps a + * debugging session working. The whole 127/8 block counts, because loopback is + * 127.0.0.1 by convention and not by rule. + */ + static boolean isLoopbackDebugProxyHost(String host) { + if (host == null) { + return false; + } + String trimmed = host.trim(); + // Brackets are how a literal IPv6 address is written in a host position. + if (trimmed.startsWith("[") && trimmed.endsWith("]")) { + trimmed = trimmed.substring(1, trimmed.length() - 1).trim(); + } + if (trimmed.equalsIgnoreCase("localhost") + || trimmed.equals("::1") + || trimmed.equals("0:0:0:0:0:0:0:1")) { + return true; + } + if (!trimmed.startsWith("127.")) { + return false; + } + // "127.0.0.1" yes, "127.0.0.1.example.com" no -- a host name may begin with + // digits, and one that merely starts with the right four characters is not + // an address at all. + String[] parts = trimmed.split("\\."); + if (parts.length != 4) { + return false; + } + for (int i = 0; i < parts.length; i++) { + if (parts[i].length() == 0 || parts[i].length() > 3) { + return false; + } + for (int c = 0; c < parts[i].length(); c++) { + if (parts[i].charAt(c) < '0' || parts[i].charAt(c) > '9') { + return false; + } + } + if (Integer.parseInt(parts[i]) > 255) { + return false; + } + } + return true; + } + private int getDeploymentTargetInt(BuildRequest request) { String target = getDeploymentTarget(request); if (target.indexOf(".") > 0) { @@ -16494,6 +16543,36 @@ public boolean accept(File file, String string) { + "NSAllowsArbitraryLoads" + ""; } + // A PHYSICAL device reaches the proxy across the Wi-Fi it shares with + // the developer's machine, and since iOS 14 that is local-network + // access: consent-gated, and gated on a purpose string the app has to + // declare up front. Without one the app is terminated the moment + // cn1_debugger dials out -- before it can connect, so the session fails + // with the proxy still waiting and nothing on the device to explain it. + // + // Only for the LAN case. The native simulator shares the host's + // loopback, which is not the local network, and an unnecessary purpose + // string puts a prompt in front of a developer who never asked for one + // -- the same reason the nearby flags are kept apart from each other. + // + // Ambiguity resolves TOWARDS declaring it: a proxyHost that is not + // recognisably loopback may still be a LAN name rather than an address, + // and the costs are not symmetric. A spare purpose string costs one + // prompt in a build that is debug-only by construction; a missing one + // costs a debugging session that cannot start. + // + // Through applyCatalogPlistEntry rather than putArgument, for the reason + // the Matter block above states: the sweep that copies + // ios.NS*UsageDescription hints into privacyUsageDescriptions ran long + // before this line, the plist is rendered from that map, and a bare + // argument set here would never be read. It fills only a MISSING value, + // so a project that declared its own string keeps it. + if (!isLoopbackDebugProxyHost(proxyHost)) { + applyCatalogPlistEntry(request, new String[] { + "NSLocalNetworkUsageDescription", + "Connects to the Codename One debugging proxy on your computer. " + + "This is a development build."}); + } } // Export compliance: when the app uses com.codename1.security.* we diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/IPhoneBuilderOnDeviceDebugLocalNetworkTest.java b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/IPhoneBuilderOnDeviceDebugLocalNetworkTest.java new file mode 100644 index 00000000000..ff419fa7bed --- /dev/null +++ b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/IPhoneBuilderOnDeviceDebugLocalNetworkTest.java @@ -0,0 +1,107 @@ +/* + * Copyright (c) 2026, 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. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.builders; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/// Deciding whether an on-device-debug proxy host is the local network. +/// +/// The on-device-debug app dials OUT to a proxy on the developer's machine. +/// For the native simulator that is the host's own loopback and no privacy +/// declaration applies; for a physical iPhone it is an address on the Wi-Fi +/// the two share, which since iOS 14 is consent-gated local-network access +/// and terminates an app that reaches it with no purpose string. The build +/// injects `NSLocalNetworkUsageDescription` for the second case and not the +/// first, so this predicate is what decides whether a debugging session on a +/// real device can connect at all. +/// +/// The asymmetry is deliberate and is what the cases below pin: anything not +/// recognisably loopback is treated as the local network. A spare purpose +/// string costs one prompt in a build that is debug-only by construction; a +/// missing one costs a session that cannot start, with the proxy still waiting +/// and nothing on the device to say why. +class IPhoneBuilderOnDeviceDebugLocalNetworkTest { + + @Test + void loopbackNeedsNoLocalNetworkDeclaration() { + assertTrue(IPhoneBuilder.isLoopbackDebugProxyHost("127.0.0.1"), + "the simulator's default proxy host is loopback"); + assertTrue(IPhoneBuilder.isLoopbackDebugProxyHost("localhost"), + "the name for it is loopback too"); + assertTrue(IPhoneBuilder.isLoopbackDebugProxyHost("LOCALHOST"), + "a host name is not case sensitive"); + assertTrue(IPhoneBuilder.isLoopbackDebugProxyHost(" 127.0.0.1 "), + "a hint value carries whatever spacing the properties file had"); + // Loopback is 127.0.0.1 by convention and the whole 127/8 block by rule, + // and a developer who binds a proxy to another address in it is still on + // loopback. + assertTrue(IPhoneBuilder.isLoopbackDebugProxyHost("127.0.0.53"), + "the whole 127/8 block is loopback, not just 127.0.0.1"); + assertTrue(IPhoneBuilder.isLoopbackDebugProxyHost("::1"), + "IPv6 loopback"); + assertTrue(IPhoneBuilder.isLoopbackDebugProxyHost("[::1]"), + "a literal IPv6 address in a host position is bracketed"); + } + + @Test + void aLanAddressIsTheLocalNetwork() { + assertFalse(IPhoneBuilder.isLoopbackDebugProxyHost("192.168.1.42"), + "the address a physical iPhone has to dial"); + assertFalse(IPhoneBuilder.isLoopbackDebugProxyHost("10.0.0.7"), "a LAN address"); + assertFalse(IPhoneBuilder.isLoopbackDebugProxyHost("172.16.4.9"), "a LAN address"); + assertFalse(IPhoneBuilder.isLoopbackDebugProxyHost("my-laptop.local"), + "a Bonjour name resolves on the local network, which is the point"); + } + + @Test + void onlyAnAddressCountsAsLoopbackNotAnythingSpelledLikeOne() { + // A host NAME may begin with digits, so "starts with 127." is not the + // question -- these all resolve wherever DNS says, which is not loopback, + // and reading them as loopback would withhold the declaration from a real + // device. + assertFalse(IPhoneBuilder.isLoopbackDebugProxyHost("127.0.0.1.example.com"), + "a name that merely begins with the loopback address is not it"); + assertFalse(IPhoneBuilder.isLoopbackDebugProxyHost("127.example.com"), + "nor is a name whose first label is 127"); + assertFalse(IPhoneBuilder.isLoopbackDebugProxyHost("127.0.0"), + "a truncated address is not an address"); + assertFalse(IPhoneBuilder.isLoopbackDebugProxyHost("127.0.0.256"), + "nor is one whose octet is out of range"); + assertFalse(IPhoneBuilder.isLoopbackDebugProxyHost("127.0.0.x"), + "nor is one that is not numeric"); + } + + @Test + void anAbsentHostIsTreatedAsTheLocalNetwork() { + // Never reached through the build, which defaults the hint to 127.0.0.1, + // but the direction matters if it ever is: unknown resolves towards + // declaring, because that is the answer that keeps a session working. + assertFalse(IPhoneBuilder.isLoopbackDebugProxyHost(null), + "an unknown host must not be assumed to be loopback"); + assertFalse(IPhoneBuilder.isLoopbackDebugProxyHost(""), + "nor an empty one"); + } +} From eb9823daddbe670878b4f42b710b8794ce37042a Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sat, 19 Sep 2026 09:37:24 +0300 Subject: [PATCH 7/8] Tell an agent the iOS local-network prompt has to be accepted on the device The build now declares NSLocalNetworkUsageDescription for a non-loopback proxy host, so the app is no longer terminated for reaching the LAN with no purpose string. What that buys is a PROMPT, and a prompt still has to be answered: until somebody taps it on the phone, the app cannot reach the proxy and the session looks exactly like a build that never dialled out. Said where the LAN address is set, because that is the line that causes it, and said with the symptom attached -- a proxy reporting nothing connected is the thing an agent will see, and the phone is the last place it would think to look. Co-Authored-By: Claude Opus 5 (1M context) --- .../src/main/resources/skill/references/on-device-debugging.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/scripts/initializr/common/src/main/resources/skill/references/on-device-debugging.md b/scripts/initializr/common/src/main/resources/skill/references/on-device-debugging.md index 9103c850bf8..81168f39238 100644 --- a/scripts/initializr/common/src/main/resources/skill/references/on-device-debugging.md +++ b/scripts/initializr/common/src/main/resources/skill/references/on-device-debugging.md @@ -77,6 +77,8 @@ codename1.arg.ios.onDeviceDebug.waitForAttach=true `proxyHost` stays `127.0.0.1` for the **native iOS simulator**, which shares your machine's loopback. For a **physical iPhone** it must be your machine's LAN address (`ifconfig` / `ipconfig`), reachable from the phone's Wi-Fi network — the phone opens the connection, so a host that only answers on loopback will never be reached. +A LAN address means the app is reaching the **local network**, which iOS 14 gates behind a consent prompt. The build declares the purpose string for you (it injects `NSLocalNetworkUsageDescription` whenever the proxy host is not loopback), but the prompt still has to be **accepted on the device** the first time the app launches, or it never reaches the proxy. If the proxy sits there reporting nothing connected, check the phone for a dialog before you check anything else. The simulator never asks, because loopback is not the local network. + **2. Build:** ```bash From a38bb3ce5dae53cc7f8bbcf1a472cc4e74faf69f Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sat, 19 Sep 2026 12:26:18 +0300 Subject: [PATCH 8/8] Backend: make epoll registration idempotent, the way kqueue already was #5868 fixed one way the arm flag could go stale and the regression test it added still failed on Linux CI -- six empty replies in one run. The message that change added is what made the next step possible: "could not re-arm fd=20", and with an errno now attached, 17. EEXIST. The descriptor was already in the host's epoll set. So the flag was never the real problem. ADD and MOD both mean "watch this descriptor for these events", and which one is correct depends on whether the kernel already holds it -- a fact the caller tracks per host, in plain arrays, written from the accepting thread and read by the host thread. Every way of getting that wrong ends the same way: epoll answers EEXIST or ENOENT, the caller sees an IOException, and it drops a connection it has already read a request from without writing a response. Chasing the individual desync paths one at a time would have been chasing symptoms; #5868 closed the biggest and six remained. The tell was that it is Linux-only. The kqueue branch has always been idempotent -- EV_ADD on a knote that exists updates it rather than refusing -- so the same mistaken flag costs nothing on macOS, which is why none of this ever reproduced in a development loop. The asymmetry WAS the bug: the flag is an optimisation that saves a syscall on the common path, and only epoll was treating it as a precondition. Each call now falls back to the other, so both platforms mean the same thing, and the flag keeps the fast path fast without deciding correctness. registerImpl also returns -errno instead of -1, and Reactor puts it in the message. What is left after the fallback is a genuine failure, and EBADF (closed under us) and EPERM (not pollable) ask for different answers from whoever reads the log -- a line saying only "could not watch fd 20" cost a whole round to classify. Measured on Linux, in a container held to 2 CPUs so the window stays open, with the same churn-around-a-parked-request workload the regression test uses: before 3 failures over 4 rounds, each with the re-arm error logged after 0 failures over 4 rounds, then 0 across 6767 requests in 8 more BackendHttpIntegrationTest is green at 72 tests locally. Co-Authored-By: Claude Opus 5 (1M context) --- .../com/codename1/backend/Reactor.java | 10 +++-- vm/backend/native/cn1_backend_server.c | 40 ++++++++++++++++++- 2 files changed, 45 insertions(+), 5 deletions(-) diff --git a/vm/backend/impl/parparvm/com/codename1/backend/Reactor.java b/vm/backend/impl/parparvm/com/codename1/backend/Reactor.java index 30ffe282ffa..b0f5ca7d428 100644 --- a/vm/backend/impl/parparvm/com/codename1/backend/Reactor.java +++ b/vm/backend/impl/parparvm/com/codename1/backend/Reactor.java @@ -62,14 +62,16 @@ public static Reactor create() throws IOException { } public void add(int fd, int events) throws IOException { - if(registerImpl(poller, fd, events, false) != 0) { - throw new IOException("Could not watch fd " + fd); + int err = registerImpl(poller, fd, events, false); + if(err != 0) { + throw new IOException("Could not watch fd " + fd + " (errno " + (-err) + ")"); } } public void modify(int fd, int events) throws IOException { - if(registerImpl(poller, fd, events, true) != 0) { - throw new IOException("Could not re-arm fd " + fd); + int err = registerImpl(poller, fd, events, true); + if(err != 0) { + throw new IOException("Could not re-arm fd " + fd + " (errno " + (-err) + ")"); } } diff --git a/vm/backend/native/cn1_backend_server.c b/vm/backend/native/cn1_backend_server.c index 33935982e15..a9cd90df106 100644 --- a/vm/backend/native/cn1_backend_server.c +++ b/vm/backend/native/cn1_backend_server.c @@ -814,7 +814,45 @@ JAVA_INT com_codename1_backend_Reactor_registerImpl___int_int_int_boolean_R_int( // the DEL + ADD that path pays, and it costs no cross-thread wake. ev.events |= EPOLLONESHOT; } - return epoll_ctl(poller, modify ? EPOLL_CTL_MOD : EPOLL_CTL_ADD, fd, &ev) == 0 ? 0 : -1; + /* + * ADD and MOD both mean "this descriptor should now be watched for these + * events", and which one is correct depends on whether the kernel already + * holds it -- which the caller tracks in a flag of its own, per host, from + * several threads. Getting that wrong is not a wrong flag: epoll answers + * EEXIST for an ADD it already has and ENOENT for a MOD it does not, the + * caller sees an IOException, and it drops a connection it has already read + * a request from WITHOUT writing a response. That is an empty reply to a + * valid request, and it is what BackendHttpIntegrationTest kept catching + * intermittently on Linux. + * + * Only on Linux, and that is the tell. The kqueue branch below has always + * been idempotent -- EV_ADD on a knote that exists updates it rather than + * refusing -- so the same mistaken flag costs nothing on macOS and the + * development loop never saw any of this. The asymmetry was the bug: the + * caller's flag is an optimisation, saving a syscall on the common path, and + * only epoll was treating it as a precondition. + * + * So each falls back to the other, and both platforms now mean the same + * thing by this call. The flag still keeps the fast path fast; it just no + * longer decides correctness. + */ + if(epoll_ctl(poller, modify ? EPOLL_CTL_MOD : EPOLL_CTL_ADD, fd, &ev) == 0) { + return 0; + } + if(!modify && errno == EEXIST) { + if(epoll_ctl(poller, EPOLL_CTL_MOD, fd, &ev) == 0) { + return 0; + } + } else if(modify && errno == ENOENT) { + if(epoll_ctl(poller, EPOLL_CTL_ADD, fd, &ev) == 0) { + return 0; + } + } + /* -errno rather than -1: what is left is a real failure, and EBADF (closed + under us) and EPERM (not pollable) ask for different answers from a reader + of the log. A bare -1 could not tell them apart, and a CI failure saying + only "could not watch fd 20" cost a round to classify. */ + return errno > 0 ? -errno : -1; #elif defined(CN1_HAVE_KQUEUE) struct kevent ev[2]; int n = 0;