diff --git a/CodenameOne/src/com/codename1/impl/CodenameOneImplementation.java b/CodenameOne/src/com/codename1/impl/CodenameOneImplementation.java index 329c97724ee..1b7c49de0a9 100644 --- a/CodenameOne/src/com/codename1/impl/CodenameOneImplementation.java +++ b/CodenameOne/src/com/codename1/impl/CodenameOneImplementation.java @@ -9917,6 +9917,69 @@ public Object listenSocket(int port) { throw new RuntimeException("Not supported"); } + /// Whether this port can bind a server socket to the LOOPBACK interface only. + /// + /// Deliberately separate from [#isServerSocketAvailable()] and defaulting to false: + /// a port must implement loopback binding explicitly. Answering this with the + /// wildcard-binding implementation would publish on every network interface a + /// channel the caller asked to keep local. + /// + /// #### Returns + /// + /// true if [#listenSocketLoopback(int)] is implemented here + public boolean isLoopbackServerSocketAvailable() { + return false; + } + + /// Closes the listening socket for the given port so a thread parked in accept comes + /// back, which is what makes stopping a listener actually stop it. Setting a flag + /// alone leaves that thread blocked until some client happens to connect, and that + /// late connection is then served or dropped by a listener the caller has abandoned. + /// + /// A port that does not implement this keeps the older behaviour, where stopping takes + /// effect on the next accept. + /// + /// #### Parameters + /// + /// - `port`: the port that was being listened on + /// + /// - `loopbackOnly`: true if the listener was created by [#listenSocketLoopback(int)] + public void stopListeningSocket(int port, boolean loopbackOnly) { + } + + /// Whether this build is a development build rather than a release build headed for + /// an app store: a debuggable Android package, a development provisioned iOS build, + /// or a JavaSE process, which covers the simulator, the designer, the desktop tooling + /// and a packaged desktop application alike - see [#isDebuggableBuild()] on the JavaSE + /// port for why it cannot tell them apart. + /// + /// Facilities that are appropriate while developing but not in a shipped app can gate + /// themselves on this. A port that cannot tell should leave the default in place: + /// answering "release" is the safe direction, because it withholds a development + /// facility rather than exposing one in production. + /// + /// #### Returns + /// + /// true if this is a development build + public boolean isDebuggableBuild() { + return false; + } + + /// Listens on the given port, bound to the loopback interface only, and blocks until + /// a connection arrives - the accept semantics of [#listenSocket(int)], with a + /// narrower bind. + /// + /// #### Parameters + /// + /// - `port`: the port to listen on + /// + /// #### Returns + /// + /// connected socket instance, or null when the accept failed + public Object listenSocketLoopback(int port) { + throw new RuntimeException("Loopback server sockets are not supported on this platform"); + } + /// Returns the device host or ip address if available /// /// #### Returns diff --git a/CodenameOne/src/com/codename1/io/Socket.java b/CodenameOne/src/com/codename1/io/Socket.java index 88a3b3411fe..12fcbfd1fb6 100644 --- a/CodenameOne/src/com/codename1/io/Socket.java +++ b/CodenameOne/src/com/codename1/io/Socket.java @@ -29,6 +29,7 @@ import java.io.IOException; import java.io.InputStream; import java.io.OutputStream; +import java.util.concurrent.atomic.AtomicBoolean; /// Class implementing the socket API /// @@ -61,6 +62,22 @@ public static boolean isServerSocketSupported() { return Util.getImplementation().isServerSocketAvailable(); } + /// Returns true if this port can listen on the LOOPBACK interface only, so the + /// channel is reachable from the device itself but not from the network. + /// + /// This is a strictly narrower capability than [#isServerSocketSupported()], which + /// binds the wildcard address and therefore publishes the port on every network + /// interface. A port must implement loopback binding explicitly; there is no + /// fallback to a wildcard bind, because silently widening the reach of a channel a + /// caller asked to keep local would be the wrong failure. + /// + /// #### Returns + /// + /// true if [#listenLoopback(int, Class)] is usable on this port + public static boolean isLoopbackServerSocketSupported() { + return Util.getImplementation().isLoopbackServerSocketAvailable(); + } + /// Connect to a remote host /// /// #### Parameters @@ -166,16 +183,88 @@ public void close() throws IOException { /// @deprecated server sockets are only supported on Android and Desktop and as such /// we recommend against using them public static StopListening listen(final int port, final Class scClass) { + return listenImpl(port, scClass, false); + } + + /// Listens on the given port on the LOOPBACK interface only, so the channel is + /// reachable from this device but not from the network. Otherwise identical to + /// [#listen(int, Class)]: `scClass` is instantiated per incoming connection and + /// must have a public no-argument constructor. + /// + /// Use this for anything that is local by nature - a debug or automation channel, + /// an on-device tool talking to a companion process. Callers that genuinely want to + /// serve the network should use [#listen(int, Class)] and say so. + /// + /// Fails (never falls back to a wildcard bind) when + /// [#isLoopbackServerSocketSupported()] is false. + /// + /// #### Parameters + /// + /// - `port`: the device port + /// + /// - `scClass`: class of callback for each incoming connection + /// + /// #### Returns + /// + /// StopListening instance that allows the caller to stop listening + /// + /// #### Throws + /// + /// - `IllegalStateException`: if this platform cannot bind a loopback server socket. + /// Thrown here rather than on the listener thread so a caller cannot walk away + /// believing it is listening when nothing ever bound. + public static StopListening listenLoopback(final int port, final Class scClass) { + if (!isLoopbackServerSocketSupported()) { + throw new IllegalStateException("This platform cannot bind a loopback server " + + "socket; check Socket.isLoopbackServerSocketSupported() first"); + } + return listenImpl(port, scClass, true); + } + + private static StopListening listenImpl(final int port, final Class scClass, final boolean loopbackOnly) { class Listener implements StopListening, Runnable { - private boolean stopped; + /// Written by stop() on the caller's thread and read by the accept loop on + /// another, so it needs a memory barrier rather than a plain field. Without + /// one the loop can miss the write, and since closing the listening socket + /// makes the port's cache hand out a FRESH socket on the next call, the + /// listener would quietly resurrect itself instead of stopping. + private final AtomicBoolean stopped = new AtomicBoolean(); + + /// The backoff after a failed accept. It starts short, doubles while the + /// failure persists and is capped, so a port that can never be bound settles + /// into one attempt every few seconds instead of two a second forever. Reset + /// as soon as an accept succeeds, so a listener that saw one bad moment is not + /// left sluggish. Slept in slices so a stop is still noticed promptly. + private static final int BACKOFF_MIN_MS = 50; + private static final int BACKOFF_MAX_MS = 5000; + private static final int BACKOFF_SLICE_MS = 50; + private int backoffMs = BACKOFF_MIN_MS; @Override public void run() { try { - while (!stopped) { - final Object connection = Util.getImplementation().listenSocket(port); + while (!stopped.get()) { + final Object connection = loopbackOnly + ? Util.getImplementation().listenSocketLoopback(port) + : Util.getImplementation().listenSocket(port); + if (stopped.get()) { + // stop() was called while this thread sat inside accept. The + // connection that unblocked it belongs to a listener the caller + // has already abandoned, so hand it back rather than serving it. + if (connection != null) { + Util.getImplementation().disconnectSocket(connection); + } + break; + } + if (connection == null && stopped.get()) { + // Closing the listening socket is how a stop is delivered, and + // it surfaces here as a failed accept. Reporting that through + // connectionError would announce a fault that never happened. + break; + } final SocketConnection sc = (SocketConnection) scClass.newInstance(); if (connection != null) { + backoffMs = BACKOFF_MIN_MS; // healthy again sc.setConnected(true); Display.getInstance().startThread(new Runnable() { @Override @@ -188,6 +277,28 @@ public void run() { }, "Connection " + port).start(); } else { sc.connectionError(Util.getImplementation().getSocketErrorCode(connection), Util.getImplementation().getSocketErrorMessage(connection)); + // A listener whose accept fails immediately and keeps failing -- + // a port that cannot be bound, say -- would otherwise spin at + // full speed, burning a core and filling the log. Pause so a + // persistent failure is slow and visible instead of hot. Only + // the failure path waits; a served connection never gets here. + // + // Slept in slices, re-testing the flag between them, so a stop + // arriving during the pause is acted on within a slice rather + // than after the whole backoff. + int waited = 0; + while (waited < backoffMs && !stopped.get()) { + int slice = Math.min(BACKOFF_SLICE_MS, backoffMs - waited); + try { + Thread.sleep(slice); + } catch (InterruptedException interrupted) { + break; // the loop re-tests stopped straight away + } + waited += slice; + } + if (backoffMs < BACKOFF_MAX_MS) { + backoffMs = Math.min(backoffMs * 2, BACKOFF_MAX_MS); + } } } } catch (InstantiationException err) { @@ -204,7 +315,13 @@ public void run() { @Override public void stop() { - stopped = true; + stopped.set(true); + // Closing the listening socket is what brings the thread back out of + // accept. Without it the flag is only noticed the next time a client + // connects, so the thread lingers, and a listener restarted on the same + // port shares the cached socket -- letting this abandoned thread win the + // race for the first connection and drop it. + Util.getImplementation().stopListeningSocket(port, loopbackOnly); } } diff --git a/CodenameOne/src/com/codename1/mcp/MCP.java b/CodenameOne/src/com/codename1/mcp/MCP.java index be6800e08a8..7a03a1bcd02 100644 --- a/CodenameOne/src/com/codename1/mcp/MCP.java +++ b/CodenameOne/src/com/codename1/mcp/MCP.java @@ -23,13 +23,16 @@ package com.codename1.mcp; import com.codename1.ai.Tool; +import com.codename1.ui.Display; /// Public entry point for the Codename One MCP headless API. Invoking a starter here is /// the enablement; there is no build hint or property to toggle. A single server /// instance exists per process. /// -/// This API is supported by the Codename One JavaSE port, which powers the simulator and -/// the desktop tooling. It is not part of a packaged application build. +/// The stdio transport is supported by the Codename One JavaSE port, which powers the +/// simulator and the desktop tooling. The socket transport also serves a build running on +/// a device, on any port that can bind the loopback interface. It is blocked on a release +/// build - see [#setAllowOnReleaseBuilds(boolean)]. /// /// #### Typical usage /// @@ -45,8 +48,15 @@ /// ``` public final class MCP { private static MCPServer server; + /// Every access to these goes through the class monitor, because the port that + /// registers a transport and the code asking whether one exists are not necessarily + /// the same thread. The accessors below are synchronized for that reason, which also + /// keeps every read and write of this state on the one lock the rest of the class + /// already takes. private static StdioTransportFactory stdioTransportFactory; private static SocketTransportFactory socketTransportFactory; + /// Lifts the release build block; see [#setAllowOnReleaseBuilds(boolean)]. + private static boolean allowOnReleaseBuilds; private MCP() { } @@ -69,23 +79,38 @@ public interface SocketTransportFactory { } /// Registers the platform stdio transport factory. Called by the JavaSE port. - public static void setStdioTransportFactory(StdioTransportFactory factory) { + public static synchronized void setStdioTransportFactory(StdioTransportFactory factory) { stdioTransportFactory = factory; } /// Registers the platform socket transport factory. Called by the JavaSE port. - public static void setSocketTransportFactory(SocketTransportFactory factory) { + public static synchronized void setSocketTransportFactory(SocketTransportFactory factory) { socketTransportFactory = factory; } + /// Whether the PORT supplied a transport of its own. Distinct from + /// [#isSocketSupported()], which also counts the portable fallback - the fallback + /// must not see itself as already installed. + static synchronized boolean hasPlatformSocketTransport() { + return socketTransportFactory != null; + } + /// Whether an stdio transport is available on this platform. - public static boolean isStdioSupported() { + public static synchronized boolean isStdioSupported() { return stdioTransportFactory != null; } - /// Whether a loopback socket transport is available on this platform. - public static boolean isSocketSupported() { - return socketTransportFactory != null; + /// Whether a loopback socket transport is available on this platform - either one the + /// port registered itself, or the portable one, which needs + /// [com.codename1.io.Socket#isLoopbackServerSocketSupported()]. + /// + /// This is a question about CAPABILITY and deliberately ignores the release build + /// gate, so it is not a complete preflight for [#startSocketServer(int)] on its own - + /// a platform that can bind may still refuse to serve. Ask + /// [#isSocketServerAllowedOnThisBuild()] for the other half. + public static synchronized boolean isSocketSupported() { + return socketTransportFactory != null + || com.codename1.io.Socket.isLoopbackServerSocketSupported(); } /// Returns the shared server, creating it on first use. @@ -114,17 +139,90 @@ public static synchronized MCPServer startStdioServer() { } /// Starts a loopback socket server so an agent can attach to this running process. - /// Requires a platform socket transport factory (registered by the JavaSE port). + /// + /// Uses the transport the port registered, if it has one - the JavaSE port registers a + /// java.net implementation. Any other port falls back to the portable transport, which + /// binds through [com.codename1.io.Socket], so this works on a device too. It fails, + /// on this thread, when the platform cannot bind a loopback server socket at all. + /// + /// Refuses to bind on a RELEASE build, throwing [IllegalStateException] - see + /// [#setAllowOnReleaseBuilds(boolean)] for why, and for the deliberate override. public static synchronized MCPServer startSocketServer(int port) { + checkAllowedOnThisBuild(); + // Ports that did not register a transport of their own fall back to the portable + // one, which binds loopback through com.codename1.io.Socket - so attaching to a + // running app on a device or simulator works, not only on the desktop. + MCPLoopbackSocketTransport.registerIfPlatformHasNone(); if (socketTransportFactory == null) { throw new IllegalStateException( - "No socket MCP transport is available on this platform. Run on the JavaSE port."); + "No socket MCP transport is available on this platform: it cannot bind a " + + "loopback server socket."); } MCPServer s = getServer(); s.start(socketTransportFactory.createSocketTransport(port)); return s; } + /// Permits the socket server to bind on a RELEASE build. Off by default, and turning + /// it on should be a deliberate, reviewed decision. + /// + /// The reason for the default: an attached agent can read the screen and drive the + /// user interface, and the loopback interface is shared by everything on the device, + /// not private to one application. On a phone that means any OTHER app installed + /// alongside yours can connect to the port and drive your application. That is a fine + /// trade while developing - it is how an agent attaches to a running app - and a poor + /// one in an app a user installs. + /// + /// A development build is detected as a debuggable Android package or a development + /// provisioned iOS build. Any other target is treated as a release build, so a port + /// that cannot tell withholds the server rather than exposing it. + /// + /// The JavaSE port is the exception, and it matters here: it reports a development + /// build in every case, because it cannot distinguish the simulator, the designer and + /// the desktop tooling from a desktop application packaged for distribution. So this + /// gate does NOT protect a shipped desktop build, and a desktop application that wants + /// the server withheld from its own release has to decide that for itself. See + /// [com.codename1.ui.Display#isDebuggableBuild()]. + /// + /// Set this only for a build that ships to a controlled fleet - a kiosk, a test lab, + /// an enterprise deployment - where the device itself is trusted. + /// + /// #### Parameters + /// + /// - `allow`: true to permit the socket server on a release build + public static synchronized void setAllowOnReleaseBuilds(boolean allow) { + allowOnReleaseBuilds = allow; + } + + /// Whether the socket server may bind on a release build. + public static synchronized boolean isAllowedOnReleaseBuilds() { + return allowOnReleaseBuilds; + } + + /// Whether the socket server is allowed to run on THIS build, which is the other half + /// of the question [#isSocketSupported()] answers: that one asks whether the platform + /// can bind a loopback server socket, this one whether it is permitted to serve. + /// [#startSocketServer(int)] needs both, and refuses on the first that says no. + /// + /// True on a development build, or on any build where + /// [#setAllowOnReleaseBuilds(boolean)] has been used. + public static synchronized boolean isSocketServerAllowedOnThisBuild() { + return allowOnReleaseBuilds || Display.getInstance().isDebuggableBuild(); + } + + /// Throws unless this build may serve MCP over a socket. + private static void checkAllowedOnThisBuild() { + if (isSocketServerAllowedOnThisBuild()) { + return; + } + throw new IllegalStateException( + "The MCP socket server is blocked on a release build. An attached agent can " + + "read the screen and drive the UI, and any other app on the device " + + "can reach the loopback port. Use a development build, or call " + + "MCP.setAllowOnReleaseBuilds(true) if this build ships to a " + + "controlled fleet."); + } + /// Stops the shared server if it is running. public static synchronized void stop() { if (server != null) { diff --git a/CodenameOne/src/com/codename1/mcp/MCPLoopbackSocketTransport.java b/CodenameOne/src/com/codename1/mcp/MCPLoopbackSocketTransport.java new file mode 100644 index 00000000000..5e715ae6fea --- /dev/null +++ b/CodenameOne/src/com/codename1/mcp/MCPLoopbackSocketTransport.java @@ -0,0 +1,435 @@ +/* + * 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.mcp; + +import com.codename1.io.Log; +import com.codename1.io.Socket; +import com.codename1.io.SocketConnection; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; + +/// Loopback socket MCP transport built on the portable [com.codename1.io.Socket] API, so +/// an agent can attach to a running app on ANY port that can bind the loopback interface - +/// a device or simulator, not only the desktop. +/// +/// The channel is bound to loopback only. That is the security boundary: an attaching +/// agent can read the screen and drive the UI, so it must already be on the device (or on +/// a host forwarding into it), never merely on the same network. There is no wildcard +/// fallback; if the port cannot bind loopback this transport refuses to open. +/// +/// The listening socket stays bound across client sessions, so an agent may disconnect and +/// reconnect - each drive call is typically its own short-lived connection - without the +/// server thread exiting. +public final class MCPLoopbackSocketTransport implements MCPTransport { + /// Lets [Connection], which the socket API instantiates reflectively via a public + /// no-argument constructor, find the transport it belongs to. A single reference + /// suffices because MCP runs one server per process. + private static MCPLoopbackSocketTransport active; + + /// Ceiling on one incoming frame. What arrives here are requests - tool calls and UI + /// actions - while the bulky payloads, screenshots among them, travel the other way, + /// so this sits far above any legitimate message. It exists to bound what an ill + /// behaved or hostile client can make the server allocate. + static final int MAX_FRAME_BYTES = 8 * 1024 * 1024; + + /// Bulk reads land here and are consumed a byte at a time. Whatever follows a + /// delimiter stays put for the next message, which is what makes buffering safe: the + /// bytes are held rather than swallowed. + /// + /// Touched only by the reader thread, and reset in readMessage whenever the attached + /// stream is not the one that filled it, so a new client never inherits a dead + /// client's tail. + private final byte[] chunk = new byte[8192]; + private int chunkPos; + private int chunkLen; + private InputStream chunkSource; // NOPMD borrowed identity, never read from or closed + + private final int port; + private final Object lock = new Object(); + private Socket.StopListening listening; + private InputStream in; + private OutputStream out; + private boolean closed; + + MCPLoopbackSocketTransport(int port) { + this.port = port; + } + + public int getPort() { + return port; + } + + /// Registers this transport as the socket factory when the platform has not supplied + /// one of its own. The JavaSE port keeps its native implementation (it can use + /// java.net directly); every other port with loopback support gets this one. + static void registerIfPlatformHasNone() { + if (MCP.hasPlatformSocketTransport()) { + return; + } + if (!Socket.isLoopbackServerSocketSupported()) { + // Registering regardless would be worse than not registering: the caller would + // see a transport, believe the server started, and only find out on the + // server's own thread that nothing can bind. Leaving the factory unset lets + // startSocketServer fail on the calling thread, where it can be handled. + return; + } + MCP.setSocketTransportFactory(new MCP.SocketTransportFactory() { + @Override + public MCPTransport createSocketTransport(int port) { + return new MCPLoopbackSocketTransport(port); + } + }); + } + + @Override + public void open() throws IOException { + if (!Socket.isLoopbackServerSocketSupported()) { + throw new IOException("This platform cannot bind a loopback server socket, so an " + + "MCP agent cannot attach to it"); + } + synchronized (MCPLoopbackSocketTransport.class) { + // Identity, not equality: is the open transport a DIFFERENT instance? + if (active != null && active != this) { // NOPMD identity is the question + throw new IOException("Another MCP socket transport is already open on port " + + active.port); + } + active = this; + } + try { + listening = Socket.listenLoopback(port, Connection.class); + } catch (RuntimeException ex) { + // Two things go wrong if this escapes. The process-wide registration would stay + // pointing at a transport that never started listening, so every later open() + // would refuse, believing an agent is already served. And the server's reader + // thread only handles IOException around open(), so a runtime exception would + // kill that thread before it could clear its running flag, leaving the server + // permanently "running" with nothing behind it. + clearActiveIfOurs(); + IOException failure = new IOException("Failed to listen on loopback port " + port); + failure.initCause(ex); + throw failure; + } + } + + /// Releases the process-wide registration, but only when it is still this transport's. + private void clearActiveIfOurs() { + synchronized (MCPLoopbackSocketTransport.class) { + if (active == this) { // NOPMD identity: is the slot still ours? + active = null; + } + } + } + + /// Called from the connection callback thread when a client attaches. The previous + /// client, if any, is dropped: one agent drives at a time. + void attach(InputStream is, OutputStream os) { + InputStream previousIn; // NOPMD closed below, deliberately outside the lock + OutputStream previousOut; // NOPMD closed below, outside the lock + synchronized (lock) { + previousIn = in; + previousOut = out; + in = is; + out = os; + lock.notifyAll(); + } + // Dropping the previous client means closing its streams, not just forgetting + // them. A reader parked in read() on the old stream is not watching this field, + // so a client that holds its connection open and sends nothing would keep the + // server serving a session that has already been replaced. + // + // Outside the lock, because closing is what wakes that reader and it takes the + // lock on its way out. + if (previousIn != is) { // NOPMD identity: the same stream re-attached is not a swap + closeQuietly(previousIn); + } + if (previousOut != os) { // NOPMD identity: as above + closeQuietly(previousOut); + } + } + + /// Closes a dropped client's stream. Failure is uninteresting: the stream is being + /// discarded either way, and closing is best effort by nature. + private static void closeQuietly(InputStream stream) { + if (stream != null) { + try { + stream.close(); + } catch (IOException ignored) { + // discarding this stream regardless + } + } + } + + private static void closeQuietly(OutputStream stream) { + if (stream != null) { + try { + stream.close(); + } catch (IOException ignored) { + // discarding this stream regardless + } + } + } + + /// Called when a client's session ends. + void detach(InputStream is) { + synchronized (lock) { + // Detach only if this is still the very same stream we attached, so a newer + // client is not torn down by a stale one finishing late. + if (in == is) { // NOPMD identity: same stream we attached? + in = null; + out = null; + lock.notifyAll(); + } + } + } + + @Override + public String readMessage() throws IOException { + while (true) { + // Borrowed reference: read the field under the lock, use it outside. The + // stream is owned by the connection callback and closed in close(); closing + // it here would end the session after a single message. + InputStream stream; // NOPMD borrowed, not owned by this method + synchronized (lock) { + while (in == null && !closed) { + try { + lock.wait(); + } catch (InterruptedException ex) { + // fall through: re-check the loop conditions + } + } + if (closed) { + return null; + } + stream = in; + } + if (stream != chunkSource) { // NOPMD identity: is this the stream we buffered? + // A different client. Anything left from the previous one belongs to a + // session that has ended and must not be read as this client's first + // message. + chunkSource = stream; + chunkPos = 0; + chunkLen = 0; + } + String line = readLine(stream); + if (line != null) { + return line; + } + // client disconnected; wait for the next one rather than ending the server + detach(stream); + if (isClosed()) { + return null; + } + } + } + + /// Reads one newline-delimited UTF-8 message. Returns null at end of stream, which + /// includes a frame cut short by the client going away. + /// + /// Reads in blocks rather than a byte at a time - a frame near the ceiling would + /// otherwise cost millions of single-byte reads. Buffering is safe here because + /// whatever follows the delimiter is KEPT in the chunk for the next call, so no bytes + /// belonging to the next message are swallowed. + private String readLine(InputStream stream) throws IOException { + ByteArrayOutputStream buffer = new ByteArrayOutputStream(); + // A carriage return is only part of the delimiter when a newline follows it, so it + // is held back one byte rather than dropped. Dropping every CR would silently edit + // a payload that happened to contain one. + boolean pendingCarriageReturn = false; + while (true) { + if (chunkPos >= chunkLen) { + int read; + try { + read = stream.read(chunk, 0, chunk.length); + } catch (IOException ex) { + return null; // a read error on the client is a disconnect + } + if (read < 0) { + // End of stream with no delimiter: the frame is truncated, so this is + // a disconnect and not a message, however many bytes arrived first. + // Handing the partial frame up would have the server fail to parse it, + // then fail to answer it (the peer is already gone), and end the loop - + // when the whole point of this transport is to stay bound and await a + // reconnect. + return null; + } + chunkPos = 0; + chunkLen = read; + if (read == 0) { + continue; // nothing this time; ask again rather than call it EOF + } + } + int b = chunk[chunkPos++] & 0xff; + if (pendingCarriageReturn) { + pendingCarriageReturn = false; + if (b == '\n') { + return toUtf8(buffer); // the CR belonged to a CRLF delimiter + } + if (!append(buffer, '\r')) { // it belonged to the payload after all + return null; + } + } + if (b == '\r') { + pendingCarriageReturn = true; + continue; + } + if (b == '\n') { + return toUtf8(buffer); + } + if (!append(buffer, b)) { + return null; + } + } + } + + /// Appends one payload byte, refusing once the frame would pass [#MAX_FRAME_BYTES]. + /// Enforced here rather than at the top of the read loop so the ceiling is exact: a + /// frame of precisely the limit is accepted and its delimiter still read, and one byte + /// beyond is refused before it is ever buffered. + /// + /// A refusal is reported to the caller as end of stream. A client sending no delimiter + /// would otherwise grow this buffer until the process ran out of memory, and on a + /// device any other app installed alongside can open this connection. + private static boolean append(ByteArrayOutputStream buffer, int b) { + if (buffer.size() >= MAX_FRAME_BYTES) { + return false; + } + buffer.write(b); + return true; + } + + /// Note for anyone tempted by ByteArrayOutputStream.toString(String), which would + /// decode without toByteArray()'s copy: core is compiled against CLDC11 + /// (Ports/CLDC11), whose ByteArrayOutputStream declares only the no-argument + /// toString(). The overload exists on the JDK and in the ParparVM runtime, so a Maven + /// build of core compiles it happily and the Ant build then fails. + private static String toUtf8(ByteArrayOutputStream buffer) throws IOException { + return new String(buffer.toByteArray(), "UTF-8"); + } + + @Override + public void writeMessage(String message) throws IOException { + // Borrowed reference (see readMessage): this writes one message, it does not own + // the session and must not close the stream. + OutputStream stream; // NOPMD borrowed, not owned by this method + synchronized (lock) { + stream = out; + } + if (stream == null) { + throw new IOException("No MCP client is attached"); + } + stream.write(message.getBytes("UTF-8")); + stream.write('\n'); + stream.flush(); + } + + private boolean isClosed() { + synchronized (lock) { + return closed; + } + } + + @Override + public void close() { + Socket.StopListening l; + // Both streams come out of their fields under the lock so they can be closed + // below, outside it: closing is what unblocks a reader parked in read(), and it + // takes the lock on its way out. + InputStream is; // NOPMD closed below, deliberately outside the lock + OutputStream os; // NOPMD closed below, outside the lock + synchronized (lock) { + closed = true; + l = listening; + listening = null; + is = in; + os = out; + in = null; + out = null; + lock.notifyAll(); + } + // Only if it is still ours: a transport opened after this one keeps its slot. + clearActiveIfOurs(); + if (l != null) { + l.stop(); + } + // Closing the output as well as the input: forgetting the field is not enough, + // because a writer that already captured it would go on writing to a session that + // has ended, and the socket would stay open until the connection callback unwound. + closeQuietly(is); + closeQuietly(os); + } + + /// The per-connection callback the socket API instantiates. Public with a no-argument + /// constructor because [Socket#listenLoopback] creates it reflectively. + public static final class Connection extends SocketConnection { + /// The last failure reported, so a listener that cannot bind at all says so once + /// instead of once per retry. Only ever touched from the listener thread. + private static String lastReportedError; + + @Override + public void connectionError(int errorCode, String message) { + // Without this the failure is silent: startSocketServer returns, the server + // reports itself running, and nothing can ever attach - a port already in use + // being the ordinary way that happens. Logged rather than thrown because this + // arrives on the listener thread, long after the caller has gone. + String description = "MCP socket listener failed: " + message + " (" + errorCode + ")"; + if (description.equals(lastReportedError)) { + return; // the same failure retrying; saying so once is enough + } + lastReportedError = description; + try { + Log.p(description, Log.ERROR); + } catch (Throwable loggingFailed) { + // The log routes through the platform implementation, which may not be + // registered yet when a server is started early in initialization. + System.err.println("[cn1.mcp] " + description); + } + } + + @Override + public void connectionEstablished(InputStream is, OutputStream os) { + MCPLoopbackSocketTransport transport; + synchronized (MCPLoopbackSocketTransport.class) { + transport = active; + } + if (transport == null) { + return; + } + transport.attach(is, os); + // Hold this callback thread for the life of the session: the socket API closes + // the streams as soon as it returns, and the server reads them from its own + // thread. + synchronized (transport.lock) { + while (transport.in == is && !transport.closed) { + try { + transport.lock.wait(); + } catch (InterruptedException ex) { + break; + } + } + } + } + } +} diff --git a/CodenameOne/src/com/codename1/ui/Display.java b/CodenameOne/src/com/codename1/ui/Display.java index 83e61ccb8e3..9618a61c2f8 100644 --- a/CodenameOne/src/com/codename1/ui/Display.java +++ b/CodenameOne/src/com/codename1/ui/Display.java @@ -6695,6 +6695,37 @@ public boolean isSimulator() { return impl.isSimulator(); } + /// Whether this build is a development build rather than a release build headed for + /// an app store. This is broader than [#isSimulator()], which reports the JavaSE + /// simulator and designer specifically and is false on a device however the build was + /// signed. Use it to gate a facility that belongs in a build you are working on but + /// not in one a user installs. + /// + /// What each port reports: + /// + /// - Android: true when the package carries the debuggable flag, which a debug build + /// sets and a release build clears. + /// + /// - iOS: true when the provisioning profile grants get-task-allow, the entitlement + /// that permits a debugger to attach. Development and ad-hoc profiles carry it; App + /// Store and enterprise profiles do not. + /// + /// - JavaSE: ALWAYS true. That port runs the simulator, the designer and the desktop + /// tooling, and it cannot distinguish those from a desktop application packaged for + /// distribution, so a packaged desktop app also reports true. Do not rely on this + /// method alone to withhold something from a shipped DESKTOP build; combine it with + /// your own signal there. + /// + /// - Any other port: false, because it cannot tell. The answer errs towards treating a + /// build as a release and withholding the facility. + /// + /// #### Returns + /// + /// true if this is a development build + public boolean isDebuggableBuild() { + return impl.isDebuggableBuild(); + } + /// Creates an audio media that can be played in the background. /// /// #### Parameters diff --git a/Ports/Android/src/com/codename1/impl/android/AndroidImplementation.java b/Ports/Android/src/com/codename1/impl/android/AndroidImplementation.java index be652ba5258..d2821da8e12 100644 --- a/Ports/Android/src/com/codename1/impl/android/AndroidImplementation.java +++ b/Ports/Android/src/com/codename1/impl/android/AndroidImplementation.java @@ -11756,19 +11756,53 @@ private synchronized ServerSockets getServerSockets() { class ServerSockets { Map socks = new HashMap(); + Map loopbackSocks = new HashMap(); public synchronized ServerSocket get(int port) throws IOException { - if (socks.containsKey(port)) { - ServerSocket sock = socks.get(port); - if (sock.isClosed()) { - sock = new ServerSocket(port); - socks.put(port, sock); + return get(port, false); + } + + /** + * When loopbackOnly is set the socket binds 127.0.0.1 rather than the wildcard + * address, so the channel isn't published on every network interface. The two + * are cached in SEPARATE maps: a port that is already bound to the wildcard + * address must never be handed back to a caller that asked for loopback. + * Distinguishing them by sign within one map would collide on port 0, the + * ephemeral-port request, where -0 == 0. + * + * The IPv4 loopback is named explicitly rather than taken from + * InetAddress.getLoopbackAddress(), which answers ::1 when the runtime + * prefers IPv6. A client that then connects to 127.0.0.1 - which is what + * adb forward and attaching agents do, and what the iOS port binds - would + * find nothing listening, with the server reporting that it had started. + */ + public synchronized ServerSocket get(int port, boolean loopbackOnly) throws IOException { + Map cache = loopbackOnly ? loopbackSocks : socks; + Integer key = Integer.valueOf(port); + ServerSocket sock = cache.get(key); + if (sock == null || sock.isClosed()) { + sock = loopbackOnly + ? new ServerSocket(port, 50, InetAddress.getByName("127.0.0.1")) + : new ServerSocket(port); + cache.put(key, sock); + } + return sock; + } + + /** + * Closes and forgets the socket, so a thread blocked in accept returns and a + * later listener on this port binds a fresh one rather than sharing this. + */ + public synchronized void close(int port, boolean loopbackOnly) { + Map cache = loopbackOnly ? loopbackSocks : socks; + ServerSocket sock = cache.remove(Integer.valueOf(port)); + if (sock != null) { + try { + sock.close(); + } catch (IOException ignored) { + // best effort: the point is to unblock accept, and a socket that + // cannot be closed is already unusable } - return sock; - } else { - ServerSocket sock = new ServerSocket(port); - socks.put(port, sock); - return sock; } } @@ -11897,15 +11931,26 @@ public void disconnect() { } public Object listen(int param) { + return listen(param, false); + } + + public Object listen(int param, boolean loopbackOnly) { + ServerSocket serverSocketInstance = null; try { - ServerSocket serverSocketInstance = getServerSockets().get(param); + serverSocketInstance = getServerSockets().get(param, loopbackOnly); socketInstance = serverSocketInstance.accept(); SocketImpl si = new SocketImpl(); si.socketInstance = socketInstance; return si; } catch(Exception err) { errorMessage = err.toString(); - err.printStackTrace(); + // A closed socket here is the deliberate stop path: stopping a + // listener closes it precisely to bring this accept back. Printing a + // stack trace for that would put an alarming fake failure in the log + // every time a listener is stopped. + if(serverSocketInstance == null || !serverSocketInstance.isClosed()) { + err.printStackTrace(); + } return null; } } @@ -11940,6 +11985,36 @@ public Object listenSocket(int port) { return new SocketImpl().listen(port); } + @Override + public boolean isLoopbackServerSocketAvailable() { + return true; + } + + @Override + public Object listenSocketLoopback(int port) { + return new SocketImpl().listen(port, true); + } + + @Override + public void stopListeningSocket(int port, boolean loopbackOnly) { + getServerSockets().close(port, loopbackOnly); + } + + /** + * A debuggable package is one built for development: the flag is set by the + * build for a debug variant and cleared for a release variant, so this reads the + * distinction straight off the installed application rather than guessing. + */ + @Override + public boolean isDebuggableBuild() { + Context ctx = getContext(); + if (ctx == null) { + return false; + } + ApplicationInfo info = ctx.getApplicationInfo(); + return info != null && (info.flags & ApplicationInfo.FLAG_DEBUGGABLE) != 0; + } + @Override public String getHostOrIP() { try { diff --git a/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java b/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java index 32820be36e2..357cf751e1d 100644 --- a/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java +++ b/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java @@ -9723,6 +9723,23 @@ public boolean isSimulator() { // differentiate simulator from JavaSE port and detect designer return designMode || getSkin() != null || widthLabel != null; } + + /// Always true, and deliberately so, but it is the weakest answer of any port. + /// + /// This port is the development environment - the simulator, the designer and the + /// desktop tooling all run on it, and that tooling already exposes itself to agents + /// from here. A desktop application packaged for distribution ALSO runs on it, and + /// nothing in the process distinguishes the two: there is no debuggable flag and no + /// provisioning profile to read, as there is on Android and iOS. Reporting a release + /// build here would take the MCP menu away from every desktop tool, which is a shipped + /// feature, so the port answers true and a packaged desktop app is not gated by it. + /// + /// A desktop application that needs to withhold something from its own release build + /// has to supply that signal itself. + @Override + public boolean isDebuggableBuild() { + return true; + } /** * @inheritDoc @@ -17122,23 +17139,50 @@ private synchronized ServerSockets getServerSockets() { class ServerSockets { Map socks = new HashMap(); - + Map loopbackSocks = new HashMap(); + public synchronized ServerSocket get(int port) throws IOException { - if (socks.containsKey(port)) { - ServerSocket sock = socks.get(port); - if (sock.isClosed()) { - sock = new ServerSocket(port); - socks.put(port, sock); + return get(port, false); + } + + /// When loopbackOnly is set the socket binds 127.0.0.1 instead of the wildcard, so + /// the channel is not published on every network interface. The two are cached in + /// SEPARATE maps: a port already bound wildcard must never be handed back to a + /// caller that asked for loopback. Distinguishing them by sign within one map + /// would collide on port 0, the ephemeral-port request, where -0 == 0. + /// + /// The IPv4 loopback is named explicitly rather than taken from + /// InetAddress.getLoopbackAddress(), which answers ::1 when the runtime prefers + /// IPv6. A client that then connects to 127.0.0.1 - which is what agents and + /// port-forwarding tools do, and what the iOS port binds - would find nothing + /// listening, with the server reporting that it had started. + public synchronized ServerSocket get(int port, boolean loopbackOnly) throws IOException { + Map cache = loopbackOnly ? loopbackSocks : socks; + Integer key = Integer.valueOf(port); + ServerSocket sock = cache.get(key); + if (sock == null || sock.isClosed()) { + sock = loopbackOnly + ? new ServerSocket(port, 50, InetAddress.getByName("127.0.0.1")) + : new ServerSocket(port); + cache.put(key, sock); + } + return sock; + } + + /// Closes and forgets the socket, so a thread blocked in accept returns and a + /// later listener on this port binds a fresh one rather than sharing this. + public synchronized void close(int port, boolean loopbackOnly) { + Map cache = loopbackOnly ? loopbackSocks : socks; + ServerSocket sock = cache.remove(Integer.valueOf(port)); + if (sock != null) { + try { + sock.close(); + } catch (IOException ignored) { + // best effort: the point is to unblock accept, and a socket that + // cannot be closed is already unusable } - return sock; - } else { - ServerSocket sock = new ServerSocket(port); - socks.put(port, sock); - return sock; } } - - } class SocketImpl { @@ -17269,13 +17313,24 @@ public void disconnect() { } public Object listen(int param) { + return listen(param, false); + } + + public Object listen(int param, boolean loopbackOnly) { + ServerSocket serverSocketInstance = null; try { - ServerSocket serverSocketInstance = getServerSockets().get(param); + serverSocketInstance = getServerSockets().get(param, loopbackOnly); socketInstance = serverSocketInstance.accept(); return this; } catch(Exception err) { errorMessage = err.toString(); - err.printStackTrace(); + // A closed socket here is the deliberate stop path: stopping a listener + // closes it precisely to bring this accept back. Printing a stack trace + // for that would put an alarming fake failure in the log every time a + // listener is stopped. + if(serverSocketInstance == null || !serverSocketInstance.isClosed()) { + err.printStackTrace(); + } return null; } } @@ -17303,7 +17358,22 @@ public Object connectSocket(String host, int port, int connectTimeout) { public Object listenSocket(int port) { return new SocketImpl().listen(port); } - + + @Override + public boolean isLoopbackServerSocketAvailable() { + return true; + } + + @Override + public Object listenSocketLoopback(int port) { + return new SocketImpl().listen(port, true); + } + + @Override + public void stopListeningSocket(int port, boolean loopbackOnly) { + getServerSockets().close(port, loopbackOnly); + } + @Override public String getHostOrIP() { try { diff --git a/Ports/iOSPort/nativeSources/IOSNative.m b/Ports/iOSPort/nativeSources/IOSNative.m index fdbb4cc31d5..ef60c5babc3 100644 --- a/Ports/iOSPort/nativeSources/IOSNative.m +++ b/Ports/iOSPort/nativeSources/IOSNative.m @@ -10845,6 +10845,95 @@ JAVA_LONG com_codename1_impl_ios_IOSNative_connectSocket___java_lang_String_int_ return (JAVA_LONG)JAVA_NULL; } +JAVA_LONG com_codename1_impl_ios_IOSNative_listenSocketLoopback___int(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT instanceObject, JAVA_INT port) { + POOL_BEGIN(); + SocketImpl* impl = [[SocketImpl alloc] init]; + BOOL b = [impl listenLoopback:port]; + POOL_END(); + if(b) { + return (JAVA_LONG)impl; + } + // ownership is transferred to Java only on success, so a failed bind or accept must + // release here or every retry leaks the peer + [impl release]; + return (JAVA_LONG)JAVA_NULL; +} + +void com_codename1_impl_ios_IOSNative_stopListeningSocket___int(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT instanceObject, JAVA_INT port) { + POOL_BEGIN(); + [SocketImpl closeLoopbackListenerForPort:port]; + POOL_END(); +} + +JAVA_BOOLEAN com_codename1_impl_ios_IOSNative_isDebuggableBuild___R_boolean(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT instanceObject) { +#if TARGET_OS_SIMULATOR + // Nothing ships from the simulator, so it is always a development build. It also has + // no provisioning profile to inspect, which is what the device path relies on. + return JAVA_TRUE; +#else + // The provisioning profile is fixed for the life of the process, so read and parse it + // once. This is consulted by the MCP gate and is public through + // Display.isDebuggableBuild(), so a caller is free to ask repeatedly, and file IO plus + // a plist parse per call would be a poor answer to that. + static JAVA_BOOLEAN cachedDebuggable = JAVA_FALSE; + static dispatch_once_t debuggableOnce; + dispatch_once(&debuggableOnce, ^{ + POOL_BEGIN(); + JAVA_BOOLEAN result = JAVA_FALSE; + // get-task-allow is the entitlement that lets a debugger attach. Development and + // ad-hoc profiles carry it; App Store and enterprise distribution profiles do not, so + // it is the honest "is this a build I am working on" signal, and unlike the Xcode + // DEBUG macro it does not depend on which configuration the build server compiled. + NSString* path = [[NSBundle mainBundle] pathForResource:@"embedded" ofType:@"mobileprovision"]; + if(path != nil) { + NSData* data = [NSData dataWithContentsOfFile:path]; + if(data != nil) { + // The profile is CMS signed, so the file as a whole is not a plist, but the + // payload it wraps is one. Cut it out and parse it properly. + // + // Do NOT do this by looking for "get-task-allow" and then hunting nearby for + // : the value that follows it is in a distribution profile, and + // the very next entitlement is often beta-reports-active, whose sits + // about 53 characters later. Any proximity window wide enough to be useful + // matches it, and the mistake lands on the unsafe side - a shipped build + // classified as debuggable. + NSData* startMarker = [@"" dataUsingEncoding:NSUTF8StringEncoding]; + NSRange whole = NSMakeRange(0, [data length]); + NSRange start = [data rangeOfData:startMarker options:0 range:whole]; + if(start.location != NSNotFound) { + NSRange rest = NSMakeRange(start.location, [data length] - start.location); + NSRange end = [data rangeOfData:endMarker options:0 range:rest]; + if(end.location != NSNotFound) { + NSUInteger length = end.location + end.length - start.location; + NSData* plistData = [data subdataWithRange:NSMakeRange(start.location, length)]; + id plist = [NSPropertyListSerialization propertyListWithData:plistData + options:NSPropertyListImmutable + format:NULL + error:NULL]; + if([plist isKindOfClass:[NSDictionary class]]) { + id entitlements = [(NSDictionary*)plist objectForKey:@"Entitlements"]; + if([entitlements isKindOfClass:[NSDictionary class]]) { + id allowed = [(NSDictionary*)entitlements objectForKey:@"get-task-allow"]; + if([allowed isKindOfClass:[NSNumber class]]) { + result = [(NSNumber*)allowed boolValue] ? JAVA_TRUE : JAVA_FALSE; + } + } + } + } + } + } + } + POOL_END(); + // Every path that could not read an answer leaves result JAVA_FALSE: an unreadable or + // unexpected profile means "treat this as a release build", which withholds the + // facility rather than exposing it. + cachedDebuggable = result; + }); + return cachedDebuggable; +#endif +} + JAVA_OBJECT com_codename1_impl_ios_IOSNative_getHostOrIP__(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT instanceObject) { POOL_BEGIN(); JAVA_OBJECT o = fromNSString(CN1_THREAD_STATE_PASS_ARG [SocketImpl getIP]); @@ -12433,6 +12522,10 @@ JAVA_LONG com_codename1_impl_ios_IOSNative_connectSocket___java_lang_String_int_ return com_codename1_impl_ios_IOSNative_connectSocket___java_lang_String_int_int(CN1_THREAD_STATE_PASS_ARG instanceObject, host, port, connectTimeout); } +JAVA_LONG com_codename1_impl_ios_IOSNative_listenSocketLoopback___int_R_long(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT instanceObject, JAVA_INT port) { + return com_codename1_impl_ios_IOSNative_listenSocketLoopback___int(CN1_THREAD_STATE_PASS_ARG instanceObject, port); +} + JAVA_OBJECT com_codename1_impl_ios_IOSNative_getHostOrIP___R_java_lang_String(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT instanceObject) { return com_codename1_impl_ios_IOSNative_getHostOrIP__(CN1_THREAD_STATE_PASS_ARG instanceObject); } diff --git a/Ports/iOSPort/nativeSources/SocketImpl.h b/Ports/iOSPort/nativeSources/SocketImpl.h index 8ad79de9bc3..dfba870fa09 100644 --- a/Ports/iOSPort/nativeSources/SocketImpl.h +++ b/Ports/iOSPort/nativeSources/SocketImpl.h @@ -1,3 +1,25 @@ +/* + * 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. + * + * 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. + */ #import @interface SocketImpl : NSObject { @@ -7,6 +29,10 @@ NSString* errorMessage; int errorCode; BOOL connected; + /// Accepted server-side sockets do raw descriptor I/O rather than going through + /// NSStream: they are serviced on a background thread whose run loop never runs, and + /// a scheduled CFStream would simply never deliver anything. -1 when unused. + int rawFd; } -(BOOL)connect:(NSString*)host port:(int)port timeout:(int)timeout; @@ -17,6 +43,10 @@ -(void)writeToStream:(NSData*)param; -(void)disconnect; -(BOOL)listen:(int)param; +-(BOOL)listenLoopback:(int)param; +/// Closes the loopback listening socket for a port, bringing a thread blocked in accept +/// back out instead of leaving it waiting for a client nobody wants any more. ++(void)closeLoopbackListenerForPort:(int)port; -(BOOL)isConnected; -(int)getErrorCode; -(BOOL)isSupported; diff --git a/Ports/iOSPort/nativeSources/SocketImpl.m b/Ports/iOSPort/nativeSources/SocketImpl.m index 622c24a8fa0..dbaed59d326 100644 --- a/Ports/iOSPort/nativeSources/SocketImpl.m +++ b/Ports/iOSPort/nativeSources/SocketImpl.m @@ -1,11 +1,47 @@ +/* + * 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. + * + * 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. + */ #import "SocketImpl.h" #include +#include +#include +#include +#include +#include +#include #include #include "xmlvm.h" #import "CodenameOne_GLViewController.h" @implementation SocketImpl +-(id)init { + self = [super init]; + if(self != nil) { + rawFd = -1; // stream-backed unless an accept installs a descriptor + } + return self; +} + static void _yield() { #ifdef NEW_CODENAME_ONE_VM CN1_YIELD_THREAD; @@ -83,6 +119,13 @@ -(BOOL)isOutputShutdown{ } -(int)getAvailableInput{ + if(rawFd >= 0) { + int pending = 0; + if(ioctl(rawFd, FIONREAD, &pending) < 0) { + return 0; + } + return pending; + } //return availableValue; return [inputStream hasBytesAvailable] ? 1 : 0; } @@ -124,7 +167,31 @@ +(NSString*)getIP{ -(NSData*)readFromStream{ uint8_t buffer[8192]; int len; - + + if(rawFd >= 0) { + // Blocking recv: the caller is a reader thread and expects to wait for data, + // and returning nil in a spin would burn the CPU. + // + // EINTR is retried rather than treated as a disconnect. This process installs + // signal handlers, so a signal arriving mid-read is an ordinary event here, and + // dropping a healthy connection because of one would be wrong. + ssize_t r; + do { + _yield(); + r = recv(rawFd, buffer, sizeof(buffer), 0); + _resume(); + } while(r < 0 && errno == EINTR); + if(r > 0) { + return [NSData dataWithBytes:buffer length:r]; + } + // End of stream or a read error: close here rather than only marking the socket + // disconnected. The Java side closes a stream by calling disconnectSocket only + // while the socket still reports itself connected, so leaving the descriptor open + // once connected is NO would strand it for the life of the process. + [self closeRawDescriptor]; + return nil; + } + if([inputStream hasBytesAvailable]) { len = [inputStream read:buffer maxLength:sizeof(buffer)]; if (len > 0) { @@ -135,10 +202,46 @@ -(NSData*)readFromStream{ } -(void)writeToStream:(NSData*)param{ + if(rawFd >= 0) { + const uint8_t* bytes = (const uint8_t*)[param bytes]; + size_t remaining = [param length]; + while(remaining > 0) { + // send() blocks once the send buffer fills, so hand the thread back to the VM + // across it exactly as the accept and recv paths do; holding it would stall + // cooperative scheduling for as long as the peer is slow to drain. + ssize_t w; + do { + _yield(); + w = send(rawFd, bytes, remaining, 0); + _resume(); + } while(w < 0 && errno == EINTR); + if(w <= 0) { + [self closeRawDescriptor]; + return; + } + bytes += w; + remaining -= w; + } + return; + } [outputStream write:[param bytes] maxLength:[param length]]; } +/// Closes the accepted descriptor once, and records the socket as disconnected. Callers +/// reach this from either direction of the connection failing. +-(void)closeRawDescriptor{ + if(rawFd >= 0) { + close(rawFd); + rawFd = -1; + } + connected = NO; +} + -(void)disconnect{ + if(rawFd >= 0) { + [self closeRawDescriptor]; + return; + } if(inputStream != nil) { [outputStream close]; [outputStream removeFromRunLoop:[NSRunLoop currentRunLoop] forMode:NSDefaultRunLoopMode]; @@ -152,9 +255,121 @@ -(void)disconnect{ } -(BOOL)listen:(int)param{ + // Wildcard-bound server sockets remain unsupported on iOS; only the loopback + // variant below is offered, so an app cannot accidentally publish a listening + // port to the network. return NO; } +/// Listening sockets bound to loopback, keyed by port, kept across accepts so a caller +/// can accept a sequence of connections the way the desktop and Android ports do. +static NSMutableDictionary* _cn1LoopbackListeners = nil; + ++(int)loopbackListenerForPort:(int)port errorOut:(NSString**)errorOut { + @synchronized([SocketImpl class]) { + if(_cn1LoopbackListeners == nil) { + _cn1LoopbackListeners = [[NSMutableDictionary alloc] init]; + } + NSNumber* key = [NSNumber numberWithInt:port]; + NSNumber* existing = [_cn1LoopbackListeners objectForKey:key]; + if(existing != nil) { + return [existing intValue]; + } + int fd = socket(AF_INET, SOCK_STREAM, 0); + if(fd < 0) { + *errorOut = @"Could not create a socket"; + return -1; + } + int yes = 1; + setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, &yes, sizeof(yes)); + struct sockaddr_in addr; + memset(&addr, 0, sizeof(addr)); + addr.sin_family = AF_INET; + addr.sin_port = htons((uint16_t)port); + // INADDR_LOOPBACK, never INADDR_ANY: this channel must not be reachable from + // the network, only from the device itself. + addr.sin_addr.s_addr = htonl(INADDR_LOOPBACK); + if(bind(fd, (struct sockaddr*)&addr, sizeof(addr)) < 0) { + *errorOut = [NSString stringWithFormat:@"Could not bind port %i on loopback", port]; + close(fd); + return -1; + } + if(listen(fd, 50) < 0) { + *errorOut = [NSString stringWithFormat:@"Could not listen on port %i", port]; + close(fd); + return -1; + } + [_cn1LoopbackListeners setObject:[NSNumber numberWithInt:fd] forKey:key]; + return fd; + } +} + +/// Closes the listening socket for a port and forgets it, so a thread blocked in accept +/// returns instead of waiting for a client that the caller no longer wants, and a later +/// listener on the same port binds a fresh descriptor. ++(void)closeLoopbackListenerForPort:(int)port { + @synchronized([SocketImpl class]) { + if(_cn1LoopbackListeners == nil) { + return; + } + NSNumber* key = [NSNumber numberWithInt:port]; + NSNumber* existing = [_cn1LoopbackListeners objectForKey:key]; + if(existing != nil) { + close([existing intValue]); + [_cn1LoopbackListeners removeObjectForKey:key]; + } + } +} + +-(BOOL)listenLoopback:(int)param{ + NSString* bindError = nil; + int listenFd = [SocketImpl loopbackListenerForPort:param errorOut:&bindError]; + if(listenFd < 0) { + errorMessage = bindError; + errorCode = -1; + connected = NO; + return NO; + } + + // accept() blocks; hand the VM back this thread while it does. EINTR is retried + // rather than reported: a signal arriving while parked here is a spurious wakeup, not + // a request to stop. Stopping arrives as a CLOSED descriptor, which is EBADF. + int clientFd; + do { + _yield(); + clientFd = accept(listenFd, NULL, NULL); + _resume(); + } while(clientFd < 0 && errno == EINTR); + if(clientFd < 0) { + int acceptErrno = errno; + errorCode = -1; + connected = NO; + // Stopping a listener is delivered by closing its descriptor, so these mean + // "you were asked to stop", not "the accept failed". Reporting them as errors + // would make every deliberate shutdown look like a fault to the Java side. + if(acceptErrno == EBADF || acceptErrno == EINVAL) { + errorMessage = nil; + } else { + errorMessage = @"Accept failed"; + } + return NO; + } + + // Serve this socket with raw descriptor I/O. An accepted connection is read on a + // background thread whose run loop never runs, so a scheduled CFStream would open + // but never deliver a byte. + // + // Writing to a peer that has gone away raises SIGPIPE by default, and this process + // installs a SIGPIPE handler (CodenameOne_GLAppDelegate), so the ordinary case of a + // client disconnecting mid-write would surface as a signal rather than as the EPIPE + // the write path is written to handle. SO_NOSIGPIPE turns it into a plain error. + int noSigPipe = 1; + setsockopt(clientFd, SOL_SOCKET, SO_NOSIGPIPE, &noSigPipe, sizeof(noSigPipe)); + rawFd = clientFd; + connected = YES; + return YES; +} + -(BOOL)isConnected{ return connected; } diff --git a/Ports/iOSPort/src/com/codename1/impl/ios/IOSImplementation.java b/Ports/iOSPort/src/com/codename1/impl/ios/IOSImplementation.java index d8033ec0b07..3a4846a86e0 100644 --- a/Ports/iOSPort/src/com/codename1/impl/ios/IOSImplementation.java +++ b/Ports/iOSPort/src/com/codename1/impl/ios/IOSImplementation.java @@ -11843,8 +11843,36 @@ public Object connectSocket(String host, int port, int connectTimeout) { @Override public Object listenSocket(int port) { + // Wildcard-bound server sockets stay unsupported on iOS; see + // listenSocketLoopback, which binds the loopback interface only. return null; } + + @Override + public boolean isLoopbackServerSocketAvailable() { + return true; + } + + @Override + public boolean isDebuggableBuild() { + return nativeInstance.isDebuggableBuild(); + } + + @Override + public void stopListeningSocket(int port, boolean loopbackOnly) { + if(loopbackOnly) { + nativeInstance.stopListeningSocket(port); + } + } + + @Override + public Object listenSocketLoopback(int port) { + long peer = nativeInstance.listenSocketLoopback(port); + if(peer == 0) { + return null; + } + return Long.valueOf(peer); + } @Override public String getHostOrIP() { diff --git a/Ports/iOSPort/src/com/codename1/impl/ios/IOSNative.java b/Ports/iOSPort/src/com/codename1/impl/ios/IOSNative.java index 4e8afbc50ac..b8477540459 100644 --- a/Ports/iOSPort/src/com/codename1/impl/ios/IOSNative.java +++ b/Ports/iOSPort/src/com/codename1/impl/ios/IOSNative.java @@ -797,6 +797,9 @@ native void gl3dDrawArrays(long contextPeer, long pipelinePeer, long vboPeer, in public native int getVKBWidth(); public native long connectSocket(String host, int port, int connectTimeout); + public native long listenSocketLoopback(int port); + public native boolean isDebuggableBuild(); + public native void stopListeningSocket(int port); public native String getHostOrIP(); public native void disconnectSocket(long socket); public native boolean isSocketConnected(long socket); diff --git a/docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/McpSnippets.java b/docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/McpSnippets.java index f0249d997e8..3f6795f4e3b 100644 --- a/docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/McpSnippets.java +++ b/docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/McpSnippets.java @@ -26,6 +26,7 @@ import com.codename1.ai.ToolHandler; import com.codename1.mcp.MCP; import com.codename1.mcp.MCPVerbosity; +import com.codename1.ui.Display; /** Compiled source snippets for the MCP headless API guide chapter. */ public class McpSnippets { @@ -49,6 +50,22 @@ public void startStdioServer() { // end::mcp-start-stdio[] } + public void allowOnReleaseBuilds() { + // tag::mcp-allow-release[] + // Only for a build that ships to devices you control, such as a kiosk fleet: + MCP.setAllowOnReleaseBuilds(true); + MCP.startSocketServer(8765); + // end::mcp-allow-release[] + } + + public void startWhenDevelopmentBuild() { + // tag::mcp-guard-start[] + if (Display.getInstance().isDebuggableBuild()) { + MCP.startSocketServer(8765); + } + // end::mcp-guard-start[] + } + public void publishTool(final String signedInUser) { // tag::mcp-add-tool[] MCP.addTool(new Tool( diff --git a/docs/developer-guide/MCP-Headless-API.asciidoc b/docs/developer-guide/MCP-Headless-API.asciidoc index 07fc5bba9e2..fb4e6a1e0b7 100644 --- a/docs/developer-guide/MCP-Headless-API.asciidoc +++ b/docs/developer-guide/MCP-Headless-API.asciidoc @@ -4,7 +4,7 @@ The Model Context Protocol (MCP) headless API lets an application expose itself The API reuses the accessibility semantics tree. The same immutable tree that describes the screen to VoiceOver and TalkBack also describes it to the agent, so any application is drivable without extra code. Application specific data and actions are exposed through the existing `com.codename1.ai.Tool` contract. -The API is supported by the JavaSE port, which powers the simulator and the desktop tooling. It's not part of a packaged application build, and it's inert on mobile and web targets. +The socket transport runs anywhere the port can bind the loopback interface, which includes an application running on a device, so an agent can attach to a build on a phone as well as to the simulator. It's blocked on a release build; see <>. The stdio transport needs process standard input and so is supported by the JavaSE port, which powers the simulator and the desktop tooling. == The MCP menu @@ -34,6 +34,31 @@ 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. +[[development-builds-only]] +== Development builds only + +The socket server refuses to bind on a release build and throws an `IllegalStateException` instead. + +The reason is what an attached agent can do. It reads the screen and drives the user interface, and the loopback interface is shared by everything on the device rather than being private to one application. On a phone that means any other installed application can connect to the port and drive yours. That's a reasonable trade while you're developing, because it's how an agent attaches to a running build, and a poor one in an application a user installs. + +A development build is a debuggable Android package or a development provisioned iOS build. Any other target counts as a release build, so a port that can't tell the difference withholds the server rather than exposing it. + +The JavaSE port is the exception, and it's worth understanding before you rely on this. That port runs the simulator, the designer and the desktop tooling, and it can't distinguish those from a desktop application packaged for distribution: there's no debuggable flag and no provisioning profile to read. It reports a development build in every case, because reporting otherwise would take the MCP menu away from every desktop tool. A packaged desktop application is therefore not gated by this, and one that needs to withhold the server from its own release build has to supply that signal itself. + +Nothing binds a port unless the application calls a starter, and an application that never calls one has no server at all. The gate matters for the case where a starter is left in the code by accident, which is the way a debugging facility usually reaches production. + +The same distinction is available to application code, so a starter can be left in place and simply not fire in a shipped build: + +[source,java] +include::../demos/common/src/main/java/com/codenameone/developerguide/snippets/McpSnippets.java[tag=mcp-guard-start,indent=0] + +A build that ships to devices you control, such as a kiosk fleet, a test lab, or a managed enterprise deployment, can lift the block: + +[source,java] +include::../demos/common/src/main/java/com/codenameone/developerguide/snippets/McpSnippets.java[tag=mcp-allow-release,indent=0] + +Treat that call the way you'd treat any other decision to expose a control surface, because the device itself becomes the boundary. + == Built-in user interface tools Every server registers a small set of tools that read and drive the screen through the accessibility tree. An agent calls `ui_snapshot` to get the semantics tree of the current form as JSON, including the identifier, role, label, value, and state of each node, and the identifiers of the actions each node supports. diff --git a/maven/core-unittests/src/test/java/com/codename1/mcp/MCPLoopbackSocketTransportTest.java b/maven/core-unittests/src/test/java/com/codename1/mcp/MCPLoopbackSocketTransportTest.java new file mode 100644 index 00000000000..9726b9a35e1 --- /dev/null +++ b/maven/core-unittests/src/test/java/com/codename1/mcp/MCPLoopbackSocketTransportTest.java @@ -0,0 +1,410 @@ +/* + * 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.mcp; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.PipedInputStream; +import java.io.PipedOutputStream; +import java.util.concurrent.atomic.AtomicLong; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.fail; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/// Framing and session handling for the portable MCP socket transport - the one that +/// lets an agent attach to an app running on a device, not only on the desktop. +/// +/// Drives the transport's own streams directly rather than through +/// {@code Socket.listenLoopback}: what is under test here is the message framing and the +/// attach / detach / close handshake. Going through the socket layer would instead be +/// exercising the platform's thread scheduling. +class MCPLoopbackSocketTransportTest { + + private MCPLoopbackSocketTransport transport; + + @AfterEach + void closeTransport() { + // The transport is process-wide; leaving one open would fail the next test. + if (transport != null) { + transport.close(); + transport = null; + } + } + + /// Waits for a condition instead of sleeping for a guessed interval. A fixed sleep + /// either wastes time or, on a loaded machine, fires before the thread it is waiting + /// on has got anywhere - which is how timing-based tests turn flaky. + private static void await(String what, java.util.concurrent.Callable condition) + throws Exception { + // nanoTime, not currentTimeMillis: a wall clock can be stepped by NTP mid-wait, + // which would either cut the wait short or extend it, and a test that fails when + // the clock is adjusted is worse than one that sleeps. + long deadline = System.nanoTime() + 10000L * 1000000L; + while (System.nanoTime() < deadline) { + if (condition.call()) { + return; + } + Thread.sleep(5); + } + fail("timed out waiting for " + what); + } + + private MCPLoopbackSocketTransport attached(String clientBytes) throws IOException { + transport = new MCPLoopbackSocketTransport(47811); + transport.attach(new ByteArrayInputStream(clientBytes.getBytes("UTF-8")), + new ByteArrayOutputStream()); + return transport; + } + + @Test + void readsWholeLineDelimitedMessages() throws Exception { + MCPLoopbackSocketTransport t = attached("{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\"}\n"); + String message = t.readMessage(); + assertNotNull(message); + assertEquals("{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\"}", message, + "the newline delimits the frame and is not part of the message"); + } + + @Test + void splitsConsecutiveMessagesArrivingTogether() throws Exception { + // Both messages in one chunk: framing must not depend on packet boundaries. + MCPLoopbackSocketTransport t = attached("{\"id\":1}\n{\"id\":2}\n"); + assertEquals("{\"id\":1}", t.readMessage()); + assertEquals("{\"id\":2}", t.readMessage()); + } + + @Test + void bufferedReadsDoNotSwallowTheNextClientsBytes() throws Exception { + // Reading in blocks means a read can pull in bytes past the delimiter. Those + // belong to the NEXT message on the same connection and must be kept, but they + // must not survive into a different client's session. + MCPLoopbackSocketTransport t = attached("{\"id\":1}\n{\"id\":2}\n{\"id\":3}\n"); + assertEquals("{\"id\":1}", t.readMessage()); + assertEquals("{\"id\":2}", t.readMessage(), "carried-over bytes must be readable"); + + // A new client arrives while the previous stream still had "{id:3}" buffered. + t.attach(new ByteArrayInputStream("{\"id\":9}\n".getBytes("UTF-8")), + new ByteArrayOutputStream()); + assertEquals("{\"id\":9}", t.readMessage(), + "the new client's first message must not be the old client's leftovers"); + } + + @Test + void toleratesCarriageReturnsBeforeTheDelimiter() throws Exception { + MCPLoopbackSocketTransport t = attached("{\"id\":1}\r\n"); + assertEquals("{\"id\":1}", t.readMessage(), + "a CRLF-writing client must not leave a stray carriage return in the JSON"); + } + + @Test + void keepsACarriageReturnThatIsNotPartOfTheDelimiter() throws Exception { + // Only CR immediately before LF is delimiter punctuation. A CR anywhere else is + // payload, and a transport must hand up the bytes it was given rather than edit + // them, however unlikely the sender. + MCPLoopbackSocketTransport t = attached("{\"a\":\"x\ry\"}\n"); + assertEquals("{\"a\":\"x\ry\"}", t.readMessage(), + "a carriage return inside the payload must survive the framing"); + } + + @Test + void treatsOnlyTheFinalCarriageReturnAsDelimiterPunctuation() throws Exception { + // "\r\r\n" is one payload CR followed by a CRLF delimiter; consuming both CRs + // would corrupt the message, and consuming neither would leave a stray byte. + MCPLoopbackSocketTransport t = attached("ab\r\r\n"); + assertEquals("ab\r", t.readMessage(), + "the trailing CRLF is the delimiter; the CR before it is content"); + } + + @Test + void writesMessagesWithATrailingDelimiter() throws Exception { + transport = new MCPLoopbackSocketTransport(47811); + ByteArrayOutputStream toClient = new ByteArrayOutputStream(); + transport.attach(new ByteArrayInputStream(new byte[0]), toClient); + transport.writeMessage("{\"id\":7}"); + assertEquals("{\"id\":7}\n", new String(toClient.toByteArray(), "UTF-8"), + "every reply must be one line so the peer can frame it"); + } + + @Test + void writeFailsWhenNoAgentIsAttached() { + transport = new MCPLoopbackSocketTransport(47811); + assertThrows(IOException.class, () -> transport.writeMessage("{\"id\":1}"), + "writing with nobody attached is an error, not a silent no-op"); + } + + @Test + void aDisconnectedClientDoesNotEndTheServer() throws Exception { + // The first client's stream ends; the transport must wait for the next agent + // instead of reporting end-of-transport, so reconnects keep working. + final MCPLoopbackSocketTransport t = new MCPLoopbackSocketTransport(47811); + transport = t; + // Counts the end-of-stream read, so the test can wait for the reader to have SEEN + // the disconnect rather than sleeping and hoping. + final AtomicLong endOfStreamReads = new AtomicLong(); + t.attach(new InputStream() { + @Override + public int read() { + endOfStreamReads.incrementAndGet(); + return -1; + } + }, new ByteArrayOutputStream()); + + final String[] out = new String[1]; + Thread reader = new Thread(() -> { + try { + out[0] = t.readMessage(); + } catch (IOException ignored) { + // surfaces as a null message below + } + }); + reader.start(); + await("the reader to consume the disconnect", () -> endOfStreamReads.get() > 0); + assertTrue(reader.isAlive(), "the reader should be waiting for the next client"); + + t.attach(new ByteArrayInputStream("{\"id\":9}\n".getBytes("UTF-8")), + new ByteArrayOutputStream()); + reader.join(3000); + assertEquals("{\"id\":9}", out[0], "the reconnecting agent's message should arrive"); + } + + @Test + void aClientThatNeverDelimitsItsFrameCannotExhaustMemory() throws Exception { + // A stream that never ends and never delimits: without a ceiling on the frame the + // reader would accumulate until the process died, and on a device any other + // installed app can open this connection. It must be dropped like any other bad + // client, leaving the server available for the next session. + final MCPLoopbackSocketTransport t = new MCPLoopbackSocketTransport(47811); + transport = t; + // Counting what the client is asked for is what distinguishes a ceiling from no + // ceiling here: the thread stays alive either way, but an uncapped reader never + // stops asking for more. + final AtomicLong served = new AtomicLong(); + t.attach(new InputStream() { + @Override + public int read() { + served.incrementAndGet(); + return 'x'; + } + }, new ByteArrayOutputStream()); + + final String[] out = new String[1]; + Thread reader = new Thread(() -> { + try { + out[0] = t.readMessage(); + } catch (IOException ignored) { + // surfaces as a null message below + } + }); + reader.start(); + // Wait for consumption to stop rather than for a fixed interval: the ceiling is + // what makes it stop, and how long 8MB takes depends on the machine. + await("the reader to stop consuming at the frame ceiling", () -> { + long before = served.get(); + Thread.sleep(50); + return before == served.get(); + }); + + t.attach(new ByteArrayInputStream("{\"id\":11}\n".getBytes("UTF-8")), + new ByteArrayOutputStream()); + reader.join(5000); + assertEquals("{\"id\":11}", out[0], + "the next client's message should arrive normally"); + } + + @Test + void aFrameOfExactlyTheCeilingIsStillDelivered() throws Exception { + // The ceiling is on the payload, so a frame of exactly that size is legal and its + // delimiter still has to be read. Rejecting at >= would have made the largest + // permitted message unsendable, which is the classic off-by-one on a limit. + final int size = MCPLoopbackSocketTransport.MAX_FRAME_BYTES; + final MCPLoopbackSocketTransport t = new MCPLoopbackSocketTransport(47811); + transport = t; + t.attach(new InputStream() { + private int pos; + + @Override + public int read() { + if (pos < size) { + pos++; + return 'x'; + } + if (pos == size) { + pos++; + return '\n'; + } + return -1; + } + }, new ByteArrayOutputStream()); + + // Read on another thread with a deadline. Should the ceiling ever go back to + // rejecting at exactly the limit, this frame is discarded as a disconnect and the + // reader parks waiting for the next client -- so a direct call here would hang CI + // instead of failing it. + final String[] out = new String[1]; + Thread reader = new Thread(() -> { + try { + out[0] = t.readMessage(); + } catch (IOException ignored) { + // surfaces as a null message below + } + }); + reader.start(); + reader.join(30000); + assertNotNull(out[0], "a frame of exactly the ceiling must be delivered"); + assertEquals(size, out[0].length(), + "the whole payload should arrive, without the delimiter"); + } + + @Test + void aFrameCutShortByADisconnectIsNotDeliveredAsAMessage() throws Exception { + // A client that dies mid-frame leaves bytes with no delimiter. Delivering those + // would have the server parse a truncated request, fail to answer it (the peer is + // gone) and end the loop; the transport must read it as a plain disconnect and + // stay available, exactly as it does for a clean end of stream. + final MCPLoopbackSocketTransport t = new MCPLoopbackSocketTransport(47811); + transport = t; + // Yields a partial frame then end of stream, counting the EOF so the test can wait + // for the reader to have reached it. + final AtomicLong truncatedReads = new AtomicLong(); + final byte[] partial = "{\"id\":1,\"method\":\"init".getBytes("UTF-8"); + t.attach(new InputStream() { + private int pos; + + @Override + public int read() { + if (pos < partial.length) { + return partial[pos++] & 0xff; + } + truncatedReads.incrementAndGet(); + return -1; + } + }, new ByteArrayOutputStream()); + + final String[] out = new String[1]; + Thread reader = new Thread(() -> { + try { + out[0] = t.readMessage(); + } catch (IOException ignored) { + // surfaces as a null message below + } + }); + reader.start(); + await("the reader to consume the truncated frame", () -> truncatedReads.get() > 0); + assertTrue(reader.isAlive(), + "a truncated frame must leave the reader waiting for the next client, " + + "not hand up a partial message"); + + t.attach(new ByteArrayInputStream("{\"id\":9}\n".getBytes("UTF-8")), + new ByteArrayOutputStream()); + reader.join(3000); + assertEquals("{\"id\":9}", out[0], + "the next agent's whole message should be what finally arrives"); + } + + @Test + void closeUnblocksAPendingRead() throws Exception { + final MCPLoopbackSocketTransport t = new MCPLoopbackSocketTransport(47811); + transport = t; + final String[] out = new String[1]; + final boolean[] done = new boolean[1]; + final Thread reader = new Thread(() -> { + try { + out[0] = t.readMessage(); + } catch (IOException ignored) { + // end of transport is reported as a null message + } + done[0] = true; + }); + reader.start(); + // Park the reader before closing, so this exercises "close wakes a waiting reader" + // rather than "close had already been called". WAITING is precisely the state + // lock.wait() puts it in. + // Polling for a parked state is not the same as sleeping and hoping: once the + // reader reaches wait() it STAYS there until close() notifies it, so this cannot + // miss the window. TIMED_WAITING is accepted too, so a future timed wait in the + // transport would not silently turn this into a ten second stall. + await("the reader to park waiting for a client", () -> { + Thread.State state = reader.getState(); + return state == Thread.State.WAITING || state == Thread.State.TIMED_WAITING; + }); + t.close(); + reader.join(3000); + + assertTrue(done[0], "close must wake a reader that is waiting for a client"); + assertNull(out[0], "end of transport is signalled by a null message"); + } + + @Test + void closingTheTransportClosesBothClientStreams() throws Exception { + // Forgetting the fields is not enough. A writer that already captured the output + // stream would go on writing into a session that has ended, and the socket would + // stay open until the connection callback happened to unwind. + final boolean[] inClosed = new boolean[1]; + final boolean[] outClosed = new boolean[1]; + transport = new MCPLoopbackSocketTransport(47811); + transport.attach(new InputStream() { + @Override + public int read() { + return -1; + } + + @Override + public void close() { + inClosed[0] = true; + } + }, new ByteArrayOutputStream() { + @Override + public void close() { + outClosed[0] = true; + } + }); + + transport.close(); + transport = null; + assertTrue(inClosed[0], "close must close the client's input stream"); + assertTrue(outClosed[0], "close must close the client's output stream too"); + } + + @Test + void aPipedClientRoundTrips() throws Exception { + // Closest thing to a real socket without one: the client end is a live pipe, so + // the read blocks until bytes actually arrive rather than hitting a buffer's end. + PipedOutputStream clientWrites = new PipedOutputStream(); + InputStream serverReads = new PipedInputStream(clientWrites, 4096); + transport = new MCPLoopbackSocketTransport(47811); + transport.attach(serverReads, new ByteArrayOutputStream()); + + clientWrites.write("{\"id\":42}\n".getBytes("UTF-8")); + clientWrites.flush(); + assertEquals("{\"id\":42}", transport.readMessage()); + } +} diff --git a/maven/core-unittests/src/test/java/com/codename1/mcp/MCPLoopbackTransportOpenTest.java b/maven/core-unittests/src/test/java/com/codename1/mcp/MCPLoopbackTransportOpenTest.java new file mode 100644 index 00000000000..c9985e747b4 --- /dev/null +++ b/maven/core-unittests/src/test/java/com/codename1/mcp/MCPLoopbackTransportOpenTest.java @@ -0,0 +1,101 @@ +/* + * 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.mcp; + +import com.codename1.junit.UITestBase; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInstance; + +import java.io.IOException; + +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/// What opening the transport does to the process-wide registration, which is the state a +/// failure is most likely to corrupt. Needs a platform implementation, unlike the framing +/// tests, which drive the transport's streams directly. +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class MCPLoopbackTransportOpenTest extends UITestBase { + + private MCPLoopbackSocketTransport first; + private MCPLoopbackSocketTransport second; + + @AfterEach + void closeTransports() { + // The registration is process-wide, so anything left open fails the next test. + if (first != null) { + first.close(); + first = null; + } + if (second != null) { + second.close(); + second = null; + } + } + + @Test + void aFailedListenNeitherStrandsTheRegistrationNorEscapesAsRuntime() throws Exception { + // The transport checks loopback support and then uses it. Report support to the + // first query only, and the listen that follows the check fails - the real race + // this path guards against. + implementation.setLoopbackSupportedOnlyOnFirstQuery(true); + first = new MCPLoopbackSocketTransport(47881); + + // IOException specifically: the server's reader thread handles only that around + // open(). A RuntimeException would kill the thread before it cleared its running + // flag, leaving the server permanently "running" with nothing behind it. + IOException failure = assertThrows(IOException.class, () -> first.open()); + String message = failure.getMessage(); + assertNotNull(message, "the failure must explain itself"); + assertTrue(message.contains("Failed to listen"), + "the failure should name what went wrong, got: " + message); + + // And the registration must be free, or every later transport would be refused on + // the grounds that an agent is already being served. + implementation.setLoopbackSupportedOnlyOnFirstQuery(false); + implementation.setServerSocketAvailable(true); + second = new MCPLoopbackSocketTransport(47882); + second.open(); + } + + @Test + void closingTheTransportStopsTheListenerRatherThanOnlyFlaggingIt() throws Exception { + // Setting a flag leaves the listener thread parked in accept until some client + // happens to connect. It then lingers, and if a listener is restarted on the same + // port they share the cached socket - so the abandoned thread can win the race for + // the first connection and drop it. Stopping has to close the listening socket. + implementation.setServerSocketAvailable(true); + first = new MCPLoopbackSocketTransport(47883); + first.open(); + assertTrue(implementation.getStoppedListeners().isEmpty(), + "nothing should be stopped while the transport is open"); + + first.close(); + first = null; + assertTrue(implementation.getStoppedListeners().contains("47883:true"), + "close must stop the loopback listener on its own port, got: " + + implementation.getStoppedListeners()); + } +} diff --git a/maven/core-unittests/src/test/java/com/codename1/mcp/MCPReleaseBuildGateTest.java b/maven/core-unittests/src/test/java/com/codename1/mcp/MCPReleaseBuildGateTest.java new file mode 100644 index 00000000000..cd7825d0284 --- /dev/null +++ b/maven/core-unittests/src/test/java/com/codename1/mcp/MCPReleaseBuildGateTest.java @@ -0,0 +1,161 @@ +/* + * 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.mcp; + +import com.codename1.junit.UITestBase; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInstance; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/// The MCP socket server must not bind on a release build. An attached agent reads the +/// screen and drives the UI, and loopback is shared by everything on the device, so on a +/// shipped app any other installed app could drive it. +/// +/// The test implementation serves accepts from an in-memory queue rather than a real +/// socket, so a start that gets past the gate reaches a running server without any port +/// being bound. The gate is observable as the difference between throwing on the calling +/// thread and returning that running server. +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class MCPReleaseBuildGateTest extends UITestBase { + + private static final String GATE = "release build"; + + @AfterEach + void clearOverride() { + MCP.setAllowOnReleaseBuilds(false); + MCP.stop(); + } + + /// Asserts the gate refused, and returns the message so a test can check it names the + /// reason rather than failing with something generic. Loopback support is ON, so the + /// only reason to refuse is the gate. + private String refusal(boolean debuggable) { + implementation.setDebuggableBuild(debuggable); + implementation.setServerSocketAvailable(true); + String message = assertThrows(IllegalStateException.class, + () -> MCP.startSocketServer(47899)).getMessage(); + // getMessage() is allowed to be null; assert it here so a future change that drops + // the message fails as a readable assertion rather than a NullPointerException in + // the caller's contains() check. + assertNotNull(message, "the refusal must explain itself"); + return message; + } + + /// Asserts the gate let the start through, leaving a running server behind. + private void assertPassesGate(boolean debuggable) { + implementation.setDebuggableBuild(debuggable); + implementation.setServerSocketAvailable(true); + assertNotNull(MCP.startSocketServer(47899), "start must return the shared server"); + assertTrue(MCP.isRunning(), "the server must be running once the gate is passed"); + } + + @Test + void blocksTheSocketServerOnAReleaseBuild() { + String message = refusal(false); + assertTrue(message.contains(GATE), + "a release build must be refused by the gate, got: " + message); + } + + @Test + void allowsTheSocketServerOnADevelopmentBuild() { + assertPassesGate(true); + } + + @Test + void explicitOverrideLiftsTheBlock() { + MCP.setAllowOnReleaseBuilds(true); + assertPassesGate(false); + } + + @Test + void theOverrideIsOffByDefault() { + // The safe direction has to be the default: a developer who never thinks about + // this ships an app that cannot be driven. + assertFalse(MCP.isAllowedOnReleaseBuilds()); + } + + @Test + void theBuildGateIsQueryableSeparatelyFromPlatformCapability() { + // A caller preflighting with isSocketSupported() alone would be misled: that asks + // whether the platform CAN bind, and a build can be capable yet not permitted. + implementation.setServerSocketAvailable(true); + + implementation.setDebuggableBuild(false); + assertFalse(MCP.isSocketServerAllowedOnThisBuild(), + "a release build must report that it is not allowed to serve"); + assertTrue(MCP.isSocketSupported(), + "capability is unchanged by the gate; the platform can still bind"); + + implementation.setDebuggableBuild(true); + assertTrue(MCP.isSocketServerAllowedOnThisBuild(), + "a development build must report that it is allowed to serve"); + + implementation.setDebuggableBuild(false); + MCP.setAllowOnReleaseBuilds(true); + assertTrue(MCP.isSocketServerAllowedOnThisBuild(), + "the override must be reflected in the preflight, not only in the start"); + } + + @Test + void aBlockedStartLeavesNoServerRunning() { + // The gate runs before the transport is registered or opened, so a refusal must + // not leave a half-started server behind. + refusal(false); + assertFalse(MCP.isRunning(), "a refused start must not leave a server running"); + } + + @Test + void theGateIsCheckedBeforeTransportAvailability() { + // Both failure modes apply at once here. The gate must win, otherwise a release + // build would be reported as a mere platform limitation and would start serving + // the moment the platform gained loopback support. + implementation.setDebuggableBuild(false); + implementation.setServerSocketAvailable(false); + String message = assertThrows(IllegalStateException.class, + () -> MCP.startSocketServer(47898)).getMessage(); + assertNotNull(message, "the refusal must explain itself"); + assertTrue(message.contains(GATE), + "the build gate must be evaluated first, got: " + message); + assertFalse(MCP.isRunning()); + } + + @Test + void aPlatformThatCannotBindLoopbackFailsOnTheCallingThread() { + // The portable transport must not register itself on a platform that cannot bind, + // because then the start would look like it succeeded and only fail later on the + // server's own thread, where a caller cannot see it. + implementation.setDebuggableBuild(true); + implementation.setServerSocketAvailable(false); + String message = assertThrows(IllegalStateException.class, + () -> MCP.startSocketServer(47897)).getMessage(); + assertNotNull(message, "the refusal must explain itself"); + assertTrue(message.contains("No socket MCP transport"), + "an unbindable platform must say so synchronously, got: " + message); + assertFalse(MCP.isRunning(), "nothing must be left running after that refusal"); + } +} diff --git a/maven/core-unittests/src/test/java/com/codename1/testing/TestCodenameOneImplementation.java b/maven/core-unittests/src/test/java/com/codename1/testing/TestCodenameOneImplementation.java index d9d71e2e30d..187484c0fa0 100644 --- a/maven/core-unittests/src/test/java/com/codename1/testing/TestCodenameOneImplementation.java +++ b/maven/core-unittests/src/test/java/com/codename1/testing/TestCodenameOneImplementation.java @@ -1281,6 +1281,10 @@ public void reset() { nativePickerTypeSupported = null; socketAvailable = true; serverSocketAvailable = false; + loopbackSupportedOnlyOnFirstQuery = false; + stoppedListeners.clear(); + loopbackQueried = false; + debuggableBuild = true; appHomePath = "file://app/"; hostOrIp = null; resetGalleryTracking(); @@ -2788,6 +2792,69 @@ public Object listenSocket(int port) { } } + private final java.util.List stoppedListeners = + java.util.Collections.synchronizedList(new java.util.ArrayList()); + + /// Records that a listener was asked to stop, as "port:loopbackOnly". Real ports close + /// the listening socket here; there is no socket to close in these tests, so what is + /// observable is that the call was made at all, and for the right listener. + @Override + public void stopListeningSocket(int port, boolean loopbackOnly) { + stoppedListeners.add(port + ":" + loopbackOnly); + } + + /// A snapshot, not the live list. Handing back the real one lets a test mutate what a + /// later assertion reads, and iterating a synchronized list safely needs external + /// locking that a test would have to remember. + public java.util.List getStoppedListeners() { + synchronized (stoppedListeners) { + return java.util.Collections.unmodifiableList( + new java.util.ArrayList(stoppedListeners)); + } + } + + private boolean loopbackSupportedOnlyOnFirstQuery; + private boolean loopbackQueried; + + /// Reports loopback support to the first caller and withholds it from every caller + /// after that, which models the capability changing between a check and the use that + /// follows it. Lets a test drive the failure path of a caller that checks first. + public void setLoopbackSupportedOnlyOnFirstQuery(boolean onlyFirst) { + this.loopbackSupportedOnlyOnFirstQuery = onlyFirst; + this.loopbackQueried = false; + } + + @Override + public boolean isLoopbackServerSocketAvailable() { + if (loopbackSupportedOnlyOnFirstQuery) { + boolean first = !loopbackQueried; + loopbackQueried = true; + return first; + } + return serverSocketAvailable; + } + + private boolean debuggableBuild = true; + + /// Lets a test choose which side of the release build gate it is on. + public void setDebuggableBuild(boolean debuggableBuild) { + this.debuggableBuild = debuggableBuild; + } + + @Override + public boolean isDebuggableBuild() { + return debuggableBuild; + } + + /// There is no real network in these tests, so a loopback accept is the same + /// simulated queue as a wildcard one. The distinction under test is the API contract + /// (callers asking for loopback get a socket, and a port that cannot bind loopback + /// refuses), not the kernel-level bind. + @Override + public Object listenSocketLoopback(int port) { + return listenSocket(port); + } + @Override public void disconnectSocket(Object socket) { if (socket instanceof TestSocket) {